feat(ee): space control to hide comments from viewers role

This commit is contained in:
Philipinho
2026-08-13 01:28:02 +01:00
parent 7439da2f6e
commit 0501b334b5
21 changed files with 406 additions and 45 deletions
+1
View File
@@ -18,6 +18,7 @@ export const Feature = {
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
@@ -89,20 +89,29 @@ export class CommentController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(input.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
async findOne(
@Body() input: CommentIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -113,7 +122,11 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return comment;
}
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
import { PageAccessService } from '../../page/page-access/page-access.service';
@Injectable()
export class CommentNotificationService {
@@ -25,6 +26,7 @@ export class CommentNotificationService {
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly pageAccessService: PageAccessService,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
@@ -48,7 +50,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
@@ -72,7 +74,16 @@ export class CommentNotificationService {
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
let accessibleUserIds = usersWithPageAccess;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
accessibleUserIds =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
accessibleUserIds,
);
}
const usersWithAccess = new Set(accessibleUserIds);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -145,7 +156,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@@ -166,6 +177,16 @@ export class CommentNotificationService {
);
if (hasPageAccess.length === 0) return;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
const editCapable =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
[commentCreatorId],
);
if (editCapable.length === 0) return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -225,7 +246,7 @@ export class CommentNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'settings'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -236,6 +257,11 @@ export class CommentNotificationService {
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
pageUrl,
spaceSettings: (space.settings ?? null) as Record<string, any> | null,
};
}
}
@@ -7,6 +7,7 @@ import {
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageAccessService {
@@ -14,6 +15,7 @@ export class PageAccessService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
/**
@@ -118,8 +120,68 @@ export class PageAccessService {
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
if (
!settings?.comments?.allowViewerComments ||
settings?.comments?.hideCommentsFromViewers
) {
throw new ForbiddenException();
}
}
async validateCanViewComments(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
const { canEdit } = await this.validateCanViewWithPermissions(page, user);
if (canEdit) {
return;
}
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (settings?.comments?.hideCommentsFromViewers) {
throw new ForbiddenException();
}
}
/**
* Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
*/
async filterUserIdsWithPageEditAccess(
spaceId: string,
pageId: string,
userIds: string[],
): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const spaceHasRestrictedPages =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
const hasRestriction =
spaceHasRestrictedPages &&
(await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
if (!hasRestriction) {
const editCapableIds =
await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
userIds,
spaceId,
);
return userIds.filter((id) => editCapableIds.has(id));
}
const results = await Promise.all(
userIds.map(async (userId) => {
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
userId,
pageId,
);
return canEdit ? userId : null;
}),
);
return results.filter((id): id is string => id !== null);
}
}
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
@IsOptional()
@IsBoolean()
hideCommentsFromViewers: boolean;
}
@@ -30,6 +30,35 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
export function validateExclusiveCommentSettings(
dto: Partial<
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
>,
settingsBefore: Record<string, any>,
): void {
if (
dto.allowViewerComments === undefined &&
dto.hideCommentsFromViewers === undefined
) {
return;
}
const allowViewerComments =
dto.allowViewerComments ??
settingsBefore.comments?.allowViewerComments ??
false;
const hideCommentsFromViewers =
dto.hideCommentsFromViewers ??
settingsBefore.comments?.hideCommentsFromViewers ??
false;
if (allowViewerComments && hideCommentsFromViewers) {
throw new BadRequestException(
"'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
);
}
}
@Injectable()
export class SpaceService {
constructor(
@@ -141,7 +170,8 @@ export class SpaceService {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
@@ -168,6 +198,17 @@ export class SpaceService {
) {
throw new ForbiddenException('This feature requires a valid license');
}
if (
updateSpaceDto.hideCommentsFromViewers === true &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.HIDE_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
const spaceBefore = await this.spaceRepo.findById(
@@ -176,6 +217,8 @@ export class SpaceService {
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
const before: Record<string, any> = {};
const after: Record<string, any> = {};
@@ -218,6 +261,23 @@ export class SpaceService {
);
}
if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
if (prev !== updateSpaceDto.hideCommentsFromViewers) {
before.hideCommentsFromViewers = prev;
after.hideCommentsFromViewers =
updateSpaceDto.hideCommentsFromViewers;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'hideCommentsFromViewers',
updateSpaceDto.hideCommentsFromViewers,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -20,6 +20,7 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable()
export class SpaceMemberRepo {
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
return new Set(rows.map((r) => r.userId));
}
async getUserIdsWithSpaceEditAccess(
userIds: string[],
spaceId: string,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const rows = await this.db
.selectFrom('spaceMembers')
.select('userId')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('groupUsers.userId', 'in', userIds)
.where('spaceMembers.spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
)
.execute();
return new Set(rows.map((r) => r.userId));
}
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('spaceMembers')
@@ -149,6 +149,17 @@ export class SpaceRepo {
.executeTakeFirst();
}
async getSpaceSettings(
spaceId: string,
): Promise<Record<string, any> | null> {
const row = await this.db
.selectFrom('spaces')
.select('settings')
.where('id', '=', spaceId)
.executeTakeFirst();
return (row?.settings as Record<string, any> | undefined) ?? null;
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
+39 -8
View File
@@ -3,6 +3,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Server, Socket } from 'socket.io';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { PageAccessService } from '../core/page/page-access/page-access.service';
import {
TREE_EVENTS,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
@@ -17,6 +19,8 @@ export class WsService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceRepo: SpaceRepo,
private readonly pageAccessService: PageAccessService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
@@ -67,9 +71,24 @@ export class WsService {
spaceId: string,
pageId: string,
data: any,
opts?: { bypassVisibilityCheck?: boolean },
): Promise<void> {
const room = getSpaceRoomName(spaceId);
if (
!opts?.bypassVisibilityCheck &&
(await this.spaceHidesCommentsFromViewers(spaceId))
) {
await this.broadcastToUsersMatching(room, null, data, (candidateIds) =>
this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
candidateIds,
),
);
return;
}
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
@@ -118,6 +137,17 @@ export class WsService {
excludeSocketId: string | null,
pageId: string,
data: any,
): Promise<void> {
await this.broadcastToUsersMatching(room, excludeSocketId, data, (ids) =>
this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, ids),
);
}
private async broadcastToUsersMatching(
room: string,
excludeSocketId: string | null,
data: any,
filterUserIds: (candidateUserIds: string[]) => Promise<string[]>,
): Promise<void> {
const sockets = await this.server.in(room).fetchSockets();
@@ -144,15 +174,9 @@ export class WsService {
const candidateUserIds = Array.from(userSocketMap.keys());
if (candidateUserIds.length === 0) return;
const authorizedUserIds =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
candidateUserIds,
);
const authorizedSet = new Set(authorizedUserIds);
const allowedSet = new Set(await filterUserIds(candidateUserIds));
for (const [userId, userSockets] of userSocketMap) {
if (authorizedSet.has(userId)) {
if (allowedSet.has(userId)) {
for (const socket of userSockets) {
socket.emit('message', data);
}
@@ -176,6 +200,13 @@ export class WsService {
return hasRestrictions;
}
private async spaceHidesCommentsFromViewers(
spaceId: string,
): Promise<boolean> {
const settings = await this.spaceRepo.getSpaceSettings(spaceId);
return settings?.comments?.hideCommentsFromViewers === true;
}
private extractPageId(data: any): string | null {
switch (data.operation) {
case 'addTreeNode':