Compare commits

...
Author SHA1 Message Date
Philipinho 0501b334b5 feat(ee): space control to hide comments from viewers role 2026-08-13 01:28:02 +01:00
21 changed files with 406 additions and 45 deletions
@@ -508,6 +508,11 @@
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Hide comments from viewers": "Hide comments from viewers",
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const canViewComments = useCanViewComments();
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
@@ -23,12 +25,18 @@ export default function Aside() {
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
useEffect(() => {
if (isAsideOpen && tab === "comments" && !canViewComments) {
setAsideState({ tab: "", isAsideOpen: false });
}
}, [isAsideOpen, tab, canViewComments, setAsideState]);
let title: string;
let component: ReactNode;
switch (tab) {
case "comments":
component = <CommentListWithTabs />;
component = canViewComments ? <CommentListWithTabs /> : null;
title = "Comments";
break;
case "toc":
+1
View File
@@ -19,6 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
@@ -0,0 +1,62 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceHideCommentsToggleProps = {
space: ISpace;
};
export default function SpaceHideCommentsToggle({
space,
}: SpaceHideCommentsToggleProps) {
const { t } = useTranslation();
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const allowViewerCommentsEnabled =
space.settings?.comments?.allowViewerComments === true;
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
const tooltipLabel = !hasHideComments
? upgradeLabel
: t("Turn off 'Allow viewers to comment' first");
const [checked, setChecked] = useState(
space.settings?.comments?.hideCommentsFromViewers === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
hideCommentsFromViewers: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hide comments from viewers")}</Text>
<Text size="sm" c="dimmed">
{t("Viewers cannot see or add comments on pages in this space.")}
</Text>
</div>
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle hide comments from viewers")}
/>
</Tooltip>
</Group>
);
}
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const hideCommentsEnabled =
space.settings?.comments?.hideCommentsFromViewers === true;
const isDisabled = !hasViewerComments || hideCommentsEnabled;
const tooltipLabel = !hasViewerComments
? upgradeLabel
: t("Turn off 'Hide comments from viewers' first");
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
</Text>
</div>
<Tooltip
label={upgradeLabel}
label={tooltipLabel}
disabled={!isDisabled}
refProp="rootRef"
>
@@ -0,0 +1,15 @@
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { extractPageSlugId } from "@/lib";
export function useCanViewComments(): boolean {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
return (
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
);
}
+27 -13
View File
@@ -82,6 +82,8 @@ import {
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
import clsx from "clsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageEditorProps {
pageId: string;
@@ -196,6 +198,7 @@ function CollabPageEditor({
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canViewComments = useCanViewComments();
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -372,6 +375,7 @@ function CollabPageEditor({
};
useEffect(() => {
if (!canViewComments) return;
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
return () => {
document.removeEventListener(
@@ -379,7 +383,7 @@ function CollabPageEditor({
handleActiveCommentEvent,
);
};
}, []);
}, [canViewComments]);
useEffect(() => {
setActiveCommentId(null);
@@ -430,7 +434,13 @@ function CollabPageEditor({
}
return (
<div className="editor-container" style={{ position: "relative" }}>
<div
className={clsx(
"editor-container",
!canViewComments && "comments-hidden",
)}
style={{ position: "relative" }}
>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@@ -480,17 +490,21 @@ function StaticPageEditor({
content: any;
ariaLabel: string;
}) {
const canViewComments = useCanViewComments();
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
<div className={clsx(!canViewComments && "comments-hidden")}>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
</div>
);
}
@@ -315,3 +315,8 @@
height: 100%;
}
}
.comments-hidden .ProseMirror .comment-mark {
background: none;
border-bottom: none;
}
@@ -58,6 +58,7 @@ import {
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -65,6 +66,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
const canViewComments = useCanViewComments();
const tocTriggerProps = useAsideTriggerProps("toc");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
@@ -105,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
{canViewComments && (
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!page?.isBase && (
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
<Divider my="lg" />
<SpaceHideCommentsToggle space={space} />
</div>
);
}
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
export interface ISpaceSettings {
@@ -36,6 +37,7 @@ export interface ISpace {
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
interface IMembership {
+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':