diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 18c981434..834768d9e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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", diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 556adbf17..137323d48 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -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 = ; + component = canViewComments ? : null; title = "Comments"; break; case "toc": diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index e50e8d70b..48ce1b365 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -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', diff --git a/apps/client/src/ee/security/components/space-hide-comments-toggle.tsx b/apps/client/src/ee/security/components/space-hide-comments-toggle.tsx new file mode 100644 index 000000000..f600929a5 --- /dev/null +++ b/apps/client/src/ee/security/components/space-hide-comments-toggle.tsx @@ -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) => { + const value = event.currentTarget.checked; + try { + await updateSpaceMutation.mutateAsync({ + spaceId: space.id, + hideCommentsFromViewers: value, + }); + setChecked(value); + } catch { + // error handled by mutation + } + }; + + return ( + +
+ {t("Hide comments from viewers")} + + {t("Viewers cannot see or add comments on pages in this space.")} + +
+ + + +
+ ); +} diff --git a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx index 88fac67fa..a903e6452 100644 --- a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx +++ b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx @@ -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({ diff --git a/apps/client/src/features/comment/hooks/use-can-view-comments.ts b/apps/client/src/features/comment/hooks/use-can-view-comments.ts new file mode 100644 index 000000000..be0c1a7dd --- /dev/null +++ b/apps/client/src/features/comment/hooks/use-can-view-comments.ts @@ -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 + ); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 7149e7938..9b65d3358 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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 ( -
+
@@ -480,17 +490,21 @@ function StaticPageEditor({ content: any; ariaLabel: string; }) { + const canViewComments = useCanViewComments(); + return ( - +
+ +
); } diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index ef61425a9..657f12ea5 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -315,3 +315,8 @@ height: 100%; } } + +.comments-hidden .ProseMirror .comment-mark { + background: none; + border-bottom: none; +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index e011e9ec4..00e3036f5 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -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) { - - - - - + {canViewComments && ( + + + + + + )} {!page?.isBase && ( diff --git a/apps/client/src/features/space/components/space-security-settings.tsx b/apps/client/src/features/space/components/space-security-settings.tsx index a18735343..e51e93fa7 100644 --- a/apps/client/src/features/space/components/space-security-settings.tsx +++ b/apps/client/src/features/space/components/space-security-settings.tsx @@ -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({ + + + +
); } diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index 6937b233c..72faa40ec 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -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 { diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 2a889fd08..6bc9d13bd 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -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', diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 22458848b..1c1da9899 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -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; } diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts index c79c2895f..d39edd1f5 100644 --- a/apps/server/src/core/notification/services/comment.notification.ts +++ b/apps/server/src/core/notification/services/comment.notification.ts @@ -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(); 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 | null, + }; } } diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts index 6d6db03fa..6b42ed486 100644 --- a/apps/server/src/core/page/page-access/page-access.service.ts +++ b/apps/server/src/core/page/page-access/page-access.service.ts @@ -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 | null; - if (!settings?.comments?.allowViewerComments) { + if ( + !settings?.comments?.allowViewerComments || + settings?.comments?.hideCommentsFromViewers + ) { throw new ForbiddenException(); } } + + async validateCanViewComments( + page: Page, + user: User, + workspaceId: string, + ): Promise { + 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 | 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 { + 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); + } } diff --git a/apps/server/src/core/space/dto/update-space.dto.ts b/apps/server/src/core/space/dto/update-space.dto.ts index 8b40e8944..8da122b59 100644 --- a/apps/server/src/core/space/dto/update-space.dto.ts +++ b/apps/server/src/core/space/dto/update-space.dto.ts @@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { @IsOptional() @IsBoolean() allowViewerComments: boolean; + + @IsOptional() + @IsBoolean() + hideCommentsFromViewers: boolean; } diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index af1a61079..d270f4fef 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -30,6 +30,35 @@ import { IAuditService, } from '../../../integrations/audit/audit.service'; +export function validateExclusiveCommentSettings( + dto: Partial< + Pick + >, + settingsBefore: Record, +): 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; + validateExclusiveCommentSettings(updateSpaceDto, settingsBefore); + const before: Record = {}; const after: Record = {}; @@ -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, diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts index 6711c30c7..fc3df1fec 100644 --- a/apps/server/src/database/repos/space/space-member.repo.ts +++ b/apps/server/src/database/repos/space/space-member.repo.ts @@ -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> { + 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 { const rows = await this.db .selectFrom('spaceMembers') diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 905d2fac6..4196c13df 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -149,6 +149,17 @@ export class SpaceRepo { .executeTakeFirst(); } + async getSpaceSettings( + spaceId: string, + ): Promise | null> { + const row = await this.db + .selectFrom('spaces') + .select('settings') + .where('id', '=', spaceId) + .executeTakeFirst(); + return (row?.settings as Record | undefined) ?? null; + } + async insertSpace( insertableSpace: InsertableSpace, trx?: KyselyTransaction, diff --git a/apps/server/src/ee b/apps/server/src/ee index 05529bcf9..f396df9bc 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 05529bcf97919d84f17442a9faf1a93904a5a85a +Subproject commit f396df9bc5aeed681a82a7265ecd7696fdd9d336 diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 3278f72cb..2cbf80d1a 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -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 { 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 { + 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, ): Promise { 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 { + const settings = await this.spaceRepo.getSpaceSettings(spaceId); + return settings?.comments?.hideCommentsFromViewers === true; + } + private extractPageId(data: any): string | null { switch (data.operation) { case 'addTreeNode':