From 3bf3ca14cd8ff38da396766067448a0ad7c21373 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 12 Aug 2025 22:40:19 -0700 Subject: [PATCH] WIP --- .../src/common/helpers/types/permission.ts | 6 + .../casl/abilities/page-ability.factory.ts | 168 +++++ apps/server/src/core/casl/casl.module.ts | 5 +- .../core/casl/interfaces/page-ability.type.ts | 19 + .../src/core/page/dto/add-page-members.dto.ts | 27 + .../src/core/page/dto/get-page-members.dto.ts | 22 + .../core/page/dto/remove-page-member.dto.ts | 7 + .../page/dto/update-page-member-role.dto.ts | 11 + .../page/dto/update-page-permission.dto.ts | 21 + apps/server/src/core/page/page.controller.ts | 170 ++++- apps/server/src/core/page/page.module.ts | 14 +- .../core/page/services/page-member.service.ts | 632 ++++++++++++++++++ apps/server/src/database/database.module.ts | 7 +- .../20250812T000805-add-page-permissions.ts | 90 +++ .../database/pagination/pagination-options.ts | 2 +- .../page/page-permission-repo.service.ts | 589 ++++++++++++++++ .../src/database/repos/page/page.repo.ts | 78 ++- .../database/repos/page/shared-pages.repo.ts | 58 ++ apps/server/src/database/types/db.d.ts | 33 +- .../server/src/database/types/entity.types.ts | 11 + apps/server/src/ee | 2 +- 21 files changed, 1940 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/core/casl/abilities/page-ability.factory.ts create mode 100644 apps/server/src/core/casl/interfaces/page-ability.type.ts create mode 100644 apps/server/src/core/page/dto/add-page-members.dto.ts create mode 100644 apps/server/src/core/page/dto/get-page-members.dto.ts create mode 100644 apps/server/src/core/page/dto/remove-page-member.dto.ts create mode 100644 apps/server/src/core/page/dto/update-page-member-role.dto.ts create mode 100644 apps/server/src/core/page/dto/update-page-permission.dto.ts create mode 100644 apps/server/src/core/page/services/page-member.service.ts create mode 100644 apps/server/src/database/migrations/20250812T000805-add-page-permissions.ts create mode 100644 apps/server/src/database/repos/page/page-permission-repo.service.ts create mode 100644 apps/server/src/database/repos/page/shared-pages.repo.ts diff --git a/apps/server/src/common/helpers/types/permission.ts b/apps/server/src/common/helpers/types/permission.ts index 1f4f8664..edebe0b2 100644 --- a/apps/server/src/common/helpers/types/permission.ts +++ b/apps/server/src/common/helpers/types/permission.ts @@ -10,6 +10,12 @@ export enum SpaceRole { READER = 'reader', // can only read pages in space } +export enum PageRole { + WRITER = 'writer', // can read and write pages in space + READER = 'reader', // can only read pages in space + RESTRICTED = 'restricted', // cannot access page +} + export enum SpaceVisibility { OPEN = 'open', // any workspace member can see that it exists and join. PRIVATE = 'private', // only added space users can see diff --git a/apps/server/src/core/casl/abilities/page-ability.factory.ts b/apps/server/src/core/casl/abilities/page-ability.factory.ts new file mode 100644 index 00000000..f7a88aca --- /dev/null +++ b/apps/server/src/core/casl/abilities/page-ability.factory.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + AbilityBuilder, + createMongoAbility, + MongoAbility, +} from '@casl/ability'; +import { PageRole, SpaceRole } from '../../../common/helpers/types/permission'; +import { User } from '@docmost/db/types/entity.types'; +import { + PagePermissionRepo, + PageMemberRole, +} from '@docmost/db/repos/page/page-permission-repo.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { + PageCaslAction, + IPageAbility, + PageCaslSubject, +} from '../interfaces/page-ability.type'; +import { findHighestUserSpaceRole } from '@docmost/db/repos/Space/utils'; +import { UserSpaceRole } from '@docmost/db/repos/space/types'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; + +@Injectable() +export default class PageAbilityFactory { + private readonly logger = new Logger(PageAbilityFactory.name); + + constructor( + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly pageRepo: PageRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} + + async createForUser(user: User, pageId: string) { + user.id = '0197750c-a70c-73a6-83ad-65a193433f5c'; + + // This opens the possibility to share pages with individual users from other Spaces + + /* + //TODO: we might account for space permission here too. + // we could just do it all here. no need to call two abilities. + const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles( + user.id, + spaceId, + ); + */ + + // const userPageRole = findHighestUserPageRole(userPageRoles); + // if no role abort + + // Check page-level permissions first if pageId provided + + const permission = await this.pagePermissionRepo.getUserPagePermission({ + pageId: pageId, + userId: user.id, + }); + + // does it pick one? what if the user has permissions via groups? what roles takes precedence? + + if (!permission) { + //TODO: it means we should use the space level permission + // need deeper understanding here though + // call the space factory? + } + + this.logger.log('permissions', permission); + if (permission) { + // make sure the permission is for this page + // or cascaded/inherited from a parent page + this.logger.debug('role', permission.role, 'cascade', permission.cascade); + if (permission.pageId !== pageId && !permission.cascade) { + this.logger.debug('no permission'); + // No explicit access and not inheriting - deny + return new AbilityBuilder>( + createMongoAbility, + ).build(); + } + } + + // if no permission should we use space permission here? + // if non, skip for default to take precedence + + switch (permission.role) { + case PageRole.WRITER: + return buildPageWriterAbility(); + case PageRole.READER: + return buildPageReaderAbility(); + case PageRole.RESTRICTED: + return buildPageRestrictedAbility(); + default: + throw new NotFoundException('Page permissions not found'); + } + } + + private buildAbilityForRole(role: string) { + switch (role) { + case PageRole.WRITER: + return buildPageWriterAbility(); + case PageRole.READER: + return buildPageReaderAbility(); + case PageRole.RESTRICTED: + return buildPageRestrictedAbility(); + default: + return new AbilityBuilder>( + createMongoAbility, + ).build(); + } + } +} + +function buildPageWriterAbility() { + const { can, build } = new AbilityBuilder>( + createMongoAbility, + ); + can(PageCaslAction.Read, PageCaslSubject.Settings); + can(PageCaslAction.Read, PageCaslSubject.Member); + can(PageCaslAction.Manage, PageCaslSubject.Page); + can(PageCaslAction.Manage, PageCaslSubject.Share); + return build(); +} + +function buildPageReaderAbility() { + const { can, build } = new AbilityBuilder>( + createMongoAbility, + ); + can(PageCaslAction.Read, PageCaslSubject.Settings); + can(PageCaslAction.Read, PageCaslSubject.Member); + can(PageCaslAction.Read, PageCaslSubject.Page); + can(PageCaslAction.Read, PageCaslSubject.Share); + return build(); +} + +function buildPageRestrictedAbility() { + const { cannot, build } = new AbilityBuilder>( + createMongoAbility, + ); + cannot(PageCaslAction.Read, PageCaslSubject.Settings); + cannot(PageCaslAction.Read, PageCaslSubject.Member); + cannot(PageCaslAction.Read, PageCaslSubject.Page); + cannot(PageCaslAction.Read, PageCaslSubject.Share); + return build(); +} + +export interface UserPageRole { + userId: string; + role: string; +} + +export function findHighestUserPageRole(userPageRoles: UserPageRole[]) { + //TODO: perhaps, we want the lowest here? + if (!userPageRoles) { + return undefined; + } + + const roleOrder: { [key in PageRole]: number } = { + [PageRole.WRITER]: 3, + [PageRole.READER]: 2, + [PageRole.RESTRICTED]: 1, + }; + let highestRole: string; + + for (const userPageRole of userPageRoles) { + const currentRole = userPageRole.role; + if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) { + highestRole = currentRole; + } + } + return highestRole; +} diff --git a/apps/server/src/core/casl/casl.module.ts b/apps/server/src/core/casl/casl.module.ts index 4dc08af2..4f1daec4 100644 --- a/apps/server/src/core/casl/casl.module.ts +++ b/apps/server/src/core/casl/casl.module.ts @@ -1,10 +1,11 @@ import { Global, Module } from '@nestjs/common'; import SpaceAbilityFactory from './abilities/space-ability.factory'; import WorkspaceAbilityFactory from './abilities/workspace-ability.factory'; +import PageAbilityFactory from './abilities/page-ability.factory'; @Global() @Module({ - providers: [WorkspaceAbilityFactory, SpaceAbilityFactory], - exports: [WorkspaceAbilityFactory, SpaceAbilityFactory], + providers: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory], + exports: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory], }) export class CaslModule {} diff --git a/apps/server/src/core/casl/interfaces/page-ability.type.ts b/apps/server/src/core/casl/interfaces/page-ability.type.ts new file mode 100644 index 00000000..79f79381 --- /dev/null +++ b/apps/server/src/core/casl/interfaces/page-ability.type.ts @@ -0,0 +1,19 @@ +export enum PageCaslAction { + Manage = 'manage', + Create = 'create', + Read = 'read', + Edit = 'edit', + Delete = 'delete', +} +export enum PageCaslSubject { + Settings = 'settings', + Member = 'member', + Page = 'page', + Share = 'share', +} + +export type IPageAbility = + | [PageCaslAction, PageCaslSubject.Settings] + | [PageCaslAction, PageCaslSubject.Member] + | [PageCaslAction, PageCaslSubject.Page] + | [PageCaslAction, PageCaslSubject.Share]; diff --git a/apps/server/src/core/page/dto/add-page-members.dto.ts b/apps/server/src/core/page/dto/add-page-members.dto.ts new file mode 100644 index 00000000..a1104ca4 --- /dev/null +++ b/apps/server/src/core/page/dto/add-page-members.dto.ts @@ -0,0 +1,27 @@ +import { ArrayMaxSize, IsArray, IsBoolean, IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { PageIdDto } from './page.dto'; +import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service'; + +export class AddPageMembersDto extends PageIdDto { + @IsEnum(PageMemberRole) + role: string; +// optional + @IsArray() + @ArrayMaxSize(25, { + message: 'userIds must be an array with no more than 25 elements', + }) + @IsUUID('all', { each: true }) + userIds: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(25, { + message: 'groupIds must be an array with no more than 25 elements', + }) + @IsUUID('all', { each: true }) + groupIds: string[]; + + @IsBoolean() + @IsOptional() + cascade?: boolean; // Apply to all child pages +} \ No newline at end of file diff --git a/apps/server/src/core/page/dto/get-page-members.dto.ts b/apps/server/src/core/page/dto/get-page-members.dto.ts new file mode 100644 index 00000000..5bda698e --- /dev/null +++ b/apps/server/src/core/page/dto/get-page-members.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator'; +import { PageIdDto } from './page.dto'; +import { Type } from 'class-transformer'; + +export class GetPageMembersDto extends PageIdDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + query?: string; +} \ No newline at end of file diff --git a/apps/server/src/core/page/dto/remove-page-member.dto.ts b/apps/server/src/core/page/dto/remove-page-member.dto.ts new file mode 100644 index 00000000..979e69d9 --- /dev/null +++ b/apps/server/src/core/page/dto/remove-page-member.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; +import { PageIdDto } from './page.dto'; + +export class RemovePageMemberDto extends PageIdDto { + @IsUUID() + memberId: string; +} \ No newline at end of file diff --git a/apps/server/src/core/page/dto/update-page-member-role.dto.ts b/apps/server/src/core/page/dto/update-page-member-role.dto.ts new file mode 100644 index 00000000..785b3b30 --- /dev/null +++ b/apps/server/src/core/page/dto/update-page-member-role.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsUUID } from 'class-validator'; +import { PageIdDto } from './page.dto'; +import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service'; + +export class UpdatePageMemberRoleDto extends PageIdDto { + @IsUUID() + memberId: string; + + @IsEnum(PageMemberRole) + role: string; +} \ No newline at end of file diff --git a/apps/server/src/core/page/dto/update-page-permission.dto.ts b/apps/server/src/core/page/dto/update-page-permission.dto.ts new file mode 100644 index 00000000..a3b173b4 --- /dev/null +++ b/apps/server/src/core/page/dto/update-page-permission.dto.ts @@ -0,0 +1,21 @@ +import { IsBoolean, IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service'; + +export class UpdatePagePermissionDto { + @IsUUID() + pageId: string; + + @IsUUID() + @IsOptional() + userId?: string; + + @IsUUID() + @IsOptional() + groupId?: string; + + @IsEnum(PageMemberRole) + role: string; + + @IsBoolean() + cascade: boolean; // Apply to all child pages +} diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 2f6dcf60..4889473a 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -32,9 +32,24 @@ import { } from '../casl/interfaces/space-ability.type'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo'; import { RecentPageDto } from './dto/recent-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto'; +import { AddPageMembersDto } from './dto/add-page-members.dto'; +import { RemovePageMemberDto } from './dto/remove-page-member.dto'; +import { UpdatePageMemberRoleDto } from './dto/update-page-member-role.dto'; +import { UpdatePagePermissionDto } from './dto/update-page-permission.dto'; +import { GetPageMembersDto } from './dto/get-page-members.dto'; +import { + PagePermissionService, + PagePermissionsResponse, +} from './services/page-member.service'; +import PageAbilityFactory from '../casl/abilities/page-ability.factory'; +import { + PageCaslAction, + PageCaslSubject, +} from '../casl/interfaces/page-ability.type'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -44,6 +59,9 @@ export class PageController { private readonly pageRepo: PageRepo, private readonly pageHistoryService: PageHistoryService, private readonly spaceAbility: SpaceAbilityFactory, + private readonly pageAbility: PageAbilityFactory, + private readonly pagePermissionService: PagePermissionService, + private readonly sharedPagesRepo: SharedPagesRepo, ) {} @HttpCode(HttpStatus.OK) @@ -61,11 +79,21 @@ 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)) { + const pageAbility = await this.pageAbility.createForUser(user, page.id); + + if (pageAbility.cannot(PageCaslAction.Read, PageCaslSubject.Page)) { throw new ForbiddenException(); } + /*const ability = await this.spaceAbility.createForUser( + user, + page.spaceId, + ); + + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + }*/ + return page; } @@ -372,4 +400,142 @@ export class PageController { } return this.pageService.getPageBreadCrumbs(page.id); } + + @HttpCode(HttpStatus.OK) + @Post('permissions/add') + async addPageMembers( + @Body() dto: AddPageMembersDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pagePermissionService.addMembersToPageBatch( + dto, + user, + workspace.id, + ); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions/remove') + async removePageMember( + @Body() dto: RemovePageMemberDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pagePermissionService.removePageMember(dto, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions/update-role') + async updatePageMemberRole( + @Body() dto: UpdatePageMemberRoleDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pagePermissionService.updatePageMemberRole(dto, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions/update') + async updatePagePermissions( + @Body() dto: UpdatePagePermissionDto, + @AuthUser() user: User, + ): Promise { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pagePermissionService.updatePagePermission(dto); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions/info') + async getPagePermissions( + @Body() dto: PageIdDto, + @AuthUser() user: User, + ): Promise { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + 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(); + } + + return this.pagePermissionService.getPagePermissions(dto.pageId); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions/list') + async getPageMembers( + @Body() dto: GetPageMembersDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + 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 pagination: PaginationOptions = { + page: dto.page || 1, + limit: dto.limit || 20, + query: dto.query, + }; + + return this.pagePermissionService.getPageMembers( + dto.pageId, + workspace.id, + pagination, + ); + } + + @HttpCode(HttpStatus.OK) + @Post('shared') + async getUserSharedPages(@AuthUser() user: User) { + return this.sharedPagesRepo.getUserSharedPages(user.id); + } } diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 42693e3d..d05affb6 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -3,12 +3,20 @@ import { PageService } from './services/page.service'; import { PageController } from './page.controller'; import { PageHistoryService } from './services/page-history.service'; import { TrashCleanupService } from './services/trash-cleanup.service'; +import { PagePermissionService } from './services/page-member.service'; +import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo'; import { StorageModule } from '../../integrations/storage/storage.module'; @Module({ controllers: [PageController], - providers: [PageService, PageHistoryService, TrashCleanupService], - exports: [PageService, PageHistoryService], - imports: [StorageModule] + providers: [ + PageService, + PageHistoryService, + TrashCleanupService, + PagePermissionService, + SharedPagesRepo, + ], + exports: [PageService, PageHistoryService, PagePermissionService], + imports: [StorageModule], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page-member.service.ts b/apps/server/src/core/page/services/page-member.service.ts new file mode 100644 index 00000000..8ff635cf --- /dev/null +++ b/apps/server/src/core/page/services/page-member.service.ts @@ -0,0 +1,632 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { + PagePermissionRepo, + PageMemberRole, +} from '@docmost/db/repos/page/page-permission-repo.service'; +import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo'; +import { AddPageMembersDto } from '../dto/add-page-members.dto'; +import { InjectKysely } from 'nestjs-kysely'; +import { Page, PagePermission, User } from '@docmost/db/types/entity.types'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { RemovePageMemberDto } from '../dto/remove-page-member.dto'; +import { UpdatePageMemberRoleDto } from '../dto/update-page-member-role.dto'; +import { UpdatePagePermissionDto } from '../dto/update-page-permission.dto'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { executeTx } from '@docmost/db/utils'; + +export interface IPagePermission { + id: string; + cascade: boolean; + member: { + id: string; + type: 'user' | 'group' | 'public'; + email?: string; + displayName?: string; + avatarUrl?: string; + workspaceRole?: string; + name?: string; + memberCount?: number; + }; + membershipRole: { + id: string; + level: string; + source: 'direct' | 'inherited'; + }; + grantedBy: { + id: string; + type: 'page' | 'space'; + title?: string; + name?: string; + parentId?: string; + }; +} + +export interface PagePermissionsResponse { + page: { + id: string; + title: string; + hasCustomPermissions: boolean; + inheritPermissions: boolean; + permissions: IPagePermission[]; + }; +} + +@Injectable() +export class PagePermissionService { + constructor( + private pageMemberRepo: PagePermissionRepo, + private pageRepo: PageRepo, + private sharedPagesRepo: SharedPagesRepo, + private userRepo: UserRepo, + private groupRepo: GroupRepo, + private spaceMemberRepo: SpaceMemberRepo, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async addUserToPage( + userId: string, + pageId: string, + role: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + await this.pageMemberRepo.insertPageMember( + { + userId: userId, + pageId: pageId, + role: role, + }, + trx, + ); + } + + async addGroupToPage( + groupId: string, + pageId: string, + role: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + await this.pageMemberRepo.insertPageMember( + { + groupId: groupId, + pageId: pageId, + role: role, + }, + trx, + ); + } + + async getPageMembers( + pageId: string, + workspaceId: string, + pagination: PaginationOptions, + ) { + const page = await this.pageRepo.findById(pageId); + // const page = await this.pageRepo.findById(pageId, { workspaceId }); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const members = await this.pageMemberRepo.getPageMembersPaginated( + pageId, + pagination, + ); + + return members; + } + + async addMembersToPageBatch( + dto: AddPageMembersDto, + authUser: User, + workspaceId: string, + ): Promise { + try { + const page = await this.pageRepo.findById(dto.pageId); + //const page = await this.pageRepo.findById(dto.pageId, { workspaceId }); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + // Validate role + if (!Object.values(PageMemberRole).includes(dto.role as PageMemberRole)) { + throw new BadRequestException(`Invalid role: ${dto.role}`); + } + + // Enable custom permissions if adding first member + /*if (!page.hasCustomPermissions) { + await this.pageRepo.update(dto.pageId, { + hasCustomPermissions: true, + inheritPermissions: false, + }); + }*/ + + // Make sure we have valid workspace users + const validUsersQuery = this.db + .selectFrom('users') + .select(['id', 'name']) + .where('users.id', 'in', dto.userIds) + .where('users.workspaceId', '=', workspaceId) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pagePermissions') + .select('id') + .whereRef('pagePermissions.userId', '=', 'users.id') + .where('pagePermissions.pageId', '=', dto.pageId), + ), + ), + ); + + const validGroupsQuery = this.db + .selectFrom('groups') + .select(['id', 'name']) + .where('groups.id', 'in', dto.groupIds) + .where('groups.workspaceId', '=', workspaceId) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pagePermissions') + .select('id') + .whereRef('pagePermissions.groupId', '=', 'groups.id') + .where('pagePermissions.pageId', '=', dto.pageId), + ), + ), + ); + + let validUsers = [], + validGroups = []; + if (dto.userIds && dto.userIds.length > 0) { + validUsers = await validUsersQuery.execute(); + } + if (dto.groupIds && dto.groupIds.length > 0) { + validGroups = await validGroupsQuery.execute(); + } + + const usersToAdd = []; + for (const user of validUsers) { + usersToAdd.push({ + pageId: dto.pageId, + userId: user.id, + role: dto.role, + addedById: authUser.id, + }); + + // Track orphaned page access if user doesn't have parent access + if (page.parentPageId && dto.role !== PageMemberRole.NONE) { + const hasParentAccess = await this.checkParentAccess( + user.id, + page.parentPageId, + ); + if (!hasParentAccess) { + await this.sharedPagesRepo.addSharedPage(user.id, dto.pageId); + } + } + } + + const groupsToAdd = []; + for (const group of validGroups) { + groupsToAdd.push({ + pageId: dto.pageId, + groupId: group.id, + role: dto.role, + addedById: authUser.id, + }); + } + + const membersToAdd = [...usersToAdd, ...groupsToAdd]; + if (membersToAdd.length > 0) { + await this.db + .insertInto('pagePermissions') + .values(membersToAdd) + .execute(); + } + + // Apply to child pages if requested + if (dto.cascade) { + await this.cascadeToChildren(dto.pageId, membersToAdd); + } + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof BadRequestException + ) { + throw error; + } + throw new BadRequestException( + 'Failed to add members to page. Please try again.', + ); + } + } + + async removePageMember( + dto: RemovePageMemberDto, + workspaceId: string, + ): Promise { + const member = await this.db + .selectFrom('pagePermissions') + .innerJoin('pages', 'pages.id', 'pagePermissions.pageId') + .select(['pagePermissions.id', 'pagePermissions.userId']) + .where('pagePermissions.id', '=', dto.memberId) + .where('pagePermissions.pageId', '=', dto.pageId) + .where('pages.workspaceId', '=', workspaceId) + .executeTakeFirst(); + + if (!member) { + throw new NotFoundException('Page member not found'); + } + + // Check if this is the last admin + const adminCount = await this.pageMemberRepo.roleCountByPageId( + PageMemberRole.ADMIN, + dto.pageId, + ); + + if (adminCount === 1) { + const memberToRemove = await this.pageMemberRepo.getPageMemberByTypeId( + dto.pageId, + { userId: member.userId }, + ); + if (memberToRemove?.role === PageMemberRole.ADMIN) { + throw new BadRequestException('Cannot remove the last admin from page'); + } + } + + await this.pageMemberRepo.removePageMemberById(dto.memberId, dto.pageId); + + // Remove from shared pages if it was tracked + if (member.userId) { + await this.sharedPagesRepo.removeSharedPage(member.userId, dto.pageId); + } + } + + async updatePageMemberRole( + dto: UpdatePageMemberRoleDto, + workspaceId: string, + ): Promise { + const member = await this.db + .selectFrom('pagePermissions') + .innerJoin('pages', 'pages.id', 'pagePermissions.pageId') + .select(['pagePermissions.id', 'pagePermissions.role']) + .where('pagePermissions.id', '=', dto.memberId) + .where('pagePermissions.pageId', '=', dto.pageId) + .where('pages.workspaceId', '=', workspaceId) + .executeTakeFirst(); + + if (!member) { + throw new NotFoundException('Page member not found'); + } + + if ( + member.role === PageMemberRole.ADMIN && + dto.role !== PageMemberRole.ADMIN + ) { + const adminCount = await this.pageMemberRepo.roleCountByPageId( + PageMemberRole.ADMIN, + dto.pageId, + ); + if (adminCount === 1) { + throw new BadRequestException('Cannot change role of the last admin'); + } + } + + await this.pageMemberRepo.updatePageMember( + { role: dto.role }, + dto.memberId, + dto.pageId, + ); + } + + async updatePagePermission( + dto: UpdatePagePermissionDto, + ): Promise { + const { pageId, userId, groupId, role, cascade } = dto; + + try { + // Validate inputs + if (!userId && !groupId) { + throw new BadRequestException( + 'Either userId or groupId must be provided', + ); + } + + if (userId && groupId) { + throw new BadRequestException('Cannot provide both userId and groupId'); + } + + if (!Object.values(PageMemberRole).includes(role as PageMemberRole)) { + throw new BadRequestException(`Invalid role: ${role}`); + } + + await executeTx(this.db, async (trx) => { + // Update the role + if (userId) { + await this.pageMemberRepo.upsertPageMember( + { + pageId, + userId, + role, + }, + trx, + ); + } else if (groupId) { + await this.pageMemberRepo.upsertPageMember( + { + pageId, + groupId, + role, + }, + trx, + ); + } + + // Mark page as having custom permissions + /* await this.pageRepo.update( + pageId, + { + hasCustomPermissions: true, + inheritPermissions: false, + }, + trx, + );*/ + + // Cascade to children if requested + if (cascade) { + const descendants = await this.pageRepo.getAllDescendants( + pageId, + trx, + ); + for (const childId of descendants) { + if (userId) { + await this.pageMemberRepo.upsertPageMember( + { + pageId: childId, + userId, + role, + }, + trx, + ); + } else if (groupId) { + await this.pageMemberRepo.upsertPageMember( + { + pageId: childId, + groupId, + role, + }, + trx, + ); + } + } + } + }); + + // Return comprehensive permission data + return this.getPagePermissions(pageId); + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException( + 'Failed to update page permissions. Please try again.', + ); + } + } + + async getPagePermissions(pageId: string): Promise { + const page = await this.pageRepo.findById(pageId, { includeSpace: true }); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const permissions: IPagePermission[] = []; + + // 1. Get direct page members + const directMembers = await this.pageMemberRepo.getPageMembers(pageId); + + // Batch fetch all users and groups + const userIds = directMembers.filter((m) => m.userId).map((m) => m.userId); + const groupIds = directMembers + .filter((m) => m.groupId) + .map((m) => m.groupId); + + const [users, groups] = await Promise.all([ + userIds.length > 0 + ? this.db + .selectFrom('users') + .selectAll() + .where('id', 'in', userIds) + .execute() + : Promise.resolve([]), + groupIds.length > 0 + ? this.db + .selectFrom('groups') + .selectAll() + .where('id', 'in', groupIds) + .execute() + : Promise.resolve([]), + ]); + + const userMap = new Map(users.map((u) => [u.id, u] as const)); + const groupMap = new Map(groups.map((g) => [g.id, g] as const)); + + // Build permissions with batch-fetched data + for (const member of directMembers) { + let memberData: any = null; + + if (member.userId) { + const user = userMap.get(member.userId); + if (user) { + memberData = { + id: user.id, + type: 'user' as const, + email: user.email, + displayName: user.name, + avatarUrl: user.avatarUrl, + workspaceRole: user.role, + }; + } + } else if (member.groupId) { + const group = groupMap.get(member.groupId); + if (group) { + memberData = { + id: group.id, + type: 'group' as const, + name: group.name, + memberCount: await this.db + .selectFrom('groupUsers') + .select((eb) => eb.fn.count('userId').as('count')) + .where('groupId', '=', group.id) + .executeTakeFirst() + .then((result) => Number(result?.count || 0)), + }; + } + } + + if (memberData) { + permissions.push({ + id: member.id, + cascade: true, // Page permissions cascade by default + member: memberData, + membershipRole: { + id: member.id, + level: member.role, + source: 'direct', + }, + grantedBy: { + id: pageId, + type: 'page', + title: page.title, + }, + }); + } + } + + // 2. Get inherited space members (if page inherits) + if (page) { + //if (page.inheritPermissions || !page.hasCustomPermissions) { + const spaceMembers = await this.spaceMemberRepo.getSpaceMembersPaginated( + page.spaceId, + { page: 1, limit: 100 }, + ); + + for (const spaceMember of spaceMembers.items as any[]) { + // Skip if user has direct page permission + const hasDirect = directMembers.some( + (dm) => + (dm.userId === spaceMember.id && spaceMember.type === 'user') || + (dm.groupId === spaceMember.id && spaceMember.type === 'group'), + ); + if (!hasDirect) { + permissions.push({ + id: `space-${spaceMember.id}`, + cascade: false, // Space permissions don't cascade to page children + member: { + id: spaceMember.id, + type: spaceMember.type as 'user' | 'group', + email: spaceMember.email, + displayName: spaceMember.name, + avatarUrl: spaceMember.avatarUrl, + name: spaceMember.name, + memberCount: Number(spaceMember.memberCount || 0), + }, + membershipRole: { + id: `space-role-${spaceMember.id}`, + level: spaceMember.role, + source: 'inherited', + }, + grantedBy: { + id: page.spaceId, + type: 'space', + name: (page as any).space?.name, + }, + }); + } + } + } + + return { + page: { + id: page.id, + title: page.title, + hasCustomPermissions: true, + inheritPermissions: false, + permissions, + }, + }; + } + + private async checkParentAccess( + userId: string, + parentPageId: string | null, + ): Promise { + if (!parentPageId) return true; // Root pages always accessible + + const parentAccess = await this.pageMemberRepo.resolveUserPageAccess( + userId, + parentPageId, + ); + return parentAccess !== null && parentAccess !== PageMemberRole.NONE; + } + + private async cascadeToChildren( + pageId: string, + membersToAdd: any[], + ): Promise { + const descendants = await this.pageRepo.getAllDescendants(pageId); + if (descendants.length === 0) return; + + // Separate user and group members for proper conflict handling + const userMembers = membersToAdd.filter((m) => m.userId); + const groupMembers = membersToAdd.filter((m) => m.groupId); + + for (const childId of descendants) { + // Handle user members with proper conflict resolution + if (userMembers.length > 0) { + const childUserMembers = userMembers.map((m) => ({ + ...m, + pageId: childId, + })); + + await this.db + .insertInto('pagePermissions') + .values(childUserMembers) + .onConflict((oc) => + oc.columns(['pageId', 'userId']).doUpdateSet({ + role: (eb) => eb.ref('excluded.role'), + updatedAt: new Date(), + }), + ) + .execute(); + } + + // Handle group members separately + if (groupMembers.length > 0) { + const childGroupMembers = groupMembers.map((m) => ({ + ...m, + pageId: childId, + })); + + await this.db + .insertInto('pagePermissions') + .values(childGroupMembers) + .onConflict((oc) => + oc.columns(['pageId', 'groupId']).doUpdateSet({ + role: (eb) => eb.ref('excluded.role'), + updatedAt: new Date(), + }), + ) + .execute(); + } + } + } +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 68c35dd3..9257a003 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -25,6 +25,7 @@ import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission-repo.service'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -75,7 +76,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, - ShareRepo + ShareRepo, + PagePermissionRepo, ], exports: [ WorkspaceRepo, @@ -90,7 +92,8 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, - ShareRepo + ShareRepo, + PagePermissionRepo, ], }) export class DatabaseModule diff --git a/apps/server/src/database/migrations/20250812T000805-add-page-permissions.ts b/apps/server/src/database/migrations/20250812T000805-add-page-permissions.ts new file mode 100644 index 00000000..81f75080 --- /dev/null +++ b/apps/server/src/database/migrations/20250812T000805-add-page-permissions.ts @@ -0,0 +1,90 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_permissions') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade'), + ) + .addColumn('group_id', 'uuid', (col) => + col.references('groups.id').onDelete('cascade'), + ) + .addColumn('page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('role', 'varchar', (col) => col.notNull()) + .addColumn('cascade', 'boolean', (col) => col.defaultTo(true).notNull()) // children can inherit + .addColumn('added_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz') + .addUniqueConstraint('unique_page_user', ['page_id', 'user_id']) + .addUniqueConstraint('unique_page_group', ['page_id', 'group_id']) + .addCheckConstraint( + 'allow_either_user_id_or_group_id_check', + sql`(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)`, + ) + .execute(); + + // Add indexes for performance + await db.schema + .createIndex('idx_page_permissions_page_id') + .on('page_permissions') + .column('page_id') + .execute(); + + await db.schema + .createIndex('idx_page_permissions_user_id') + .on('page_permissions') + .column('user_id') + .execute(); + + await db.schema + .createIndex('idx_page_permissions_group_id') + .on('page_permissions') + .column('group_id') + .execute(); + + // Create user_shared_pages table for tracking orphaned page access + await db.schema + .createTable('user_shared_pages') + .addColumn('user_id', 'uuid', (col) => + col.notNull().references('users.id').onDelete('cascade'), + ) + .addColumn('page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('shared_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addPrimaryKeyConstraint('user_shared_pages_pkey', ['user_id', 'page_id']) + .execute(); + + await db.schema + .createIndex('idx_user_shared_pages_user_id') + .on('user_shared_pages') + .column('user_id') + .execute(); + + await db.schema + .createIndex('idx_user_shared_pages_shared_at') + .on('user_shared_pages') + .column('shared_at') + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop user_shared_pages table + await db.schema.dropTable('user_shared_pages').execute(); + + await db.schema.dropTable('page_permissions').execute(); +} diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts index e0481910..9962c27f 100644 --- a/apps/server/src/database/pagination/pagination-options.ts +++ b/apps/server/src/database/pagination/pagination-options.ts @@ -22,5 +22,5 @@ export class PaginationOptions { @IsOptional() @IsString() - query: string; + query?: string; } diff --git a/apps/server/src/database/repos/page/page-permission-repo.service.ts b/apps/server/src/database/repos/page/page-permission-repo.service.ts new file mode 100644 index 00000000..74fc3f1d --- /dev/null +++ b/apps/server/src/database/repos/page/page-permission-repo.service.ts @@ -0,0 +1,589 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { sql } from 'kysely'; +import { + InsertablePagePermission, + PagePermission, + UpdatablePagePermission, +} from '../../types/entity.types'; +import { PaginationOptions } from '../../pagination/pagination-options'; +import { executeWithPagination } from '../../pagination/pagination'; +import { GroupRepo } from '../group/group.repo'; +import { PageRepo } from './page.repo'; +import { dbOrTx } from '@docmost/db/utils'; + +export interface UserPageRole { + userId: string; + role: string; +} + +export interface MemberInfo { + id: string; + name: string; + email?: string; + avatarUrl?: string; + memberCount?: number; + isDefault?: boolean; + type: 'user' | 'group'; +} + +export enum PageMemberRole { + ADMIN = 'admin', + WRITER = 'writer', + READER = 'reader', + NONE = 'none', +} + +@Injectable() +export class PagePermissionRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly groupRepo: GroupRepo, + private readonly pageRepo: PageRepo, + ) {} + + async insertPageMember( + insertablePageMember: InsertablePagePermission, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .insertInto('pagePermissions') + .values(insertablePageMember) + .returningAll() + .execute(); + } + + async upsertPageMember( + insertablePageMember: InsertablePagePermission, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + // Check if member exists + const existing = await this.getPageMemberByTypeId( + insertablePageMember.pageId, + { + userId: insertablePageMember.userId, + groupId: insertablePageMember.groupId, + }, + trx, + ); + + if (existing) { + // Update existing member + await db + .updateTable('pagePermissions') + .set({ role: insertablePageMember.role, updatedAt: new Date() }) + .where('id', '=', existing.id) + .execute(); + } else { + // Insert new member + await this.insertPageMember(insertablePageMember, trx); + } + } + + async updatePageMember( + updatablePageMember: UpdatablePagePermission, + pageMemberId: string, + pageId: string, + ): Promise { + await this.db + .updateTable('pagePermissions') + .set(updatablePageMember) + .where('id', '=', pageMemberId) + .where('pageId', '=', pageId) + .execute(); + } + + async getPageMemberByTypeId( + pageId: string, + opts: { + userId?: string; + groupId?: string; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + let query = db + .selectFrom('pagePermissions') + .selectAll() + .where('pageId', '=', pageId); + if (opts.userId) { + query = query.where('userId', '=', opts.userId); + } else if (opts.groupId) { + query = query.where('groupId', '=', opts.groupId); + } else { + throw new BadRequestException('Please provide a userId or groupId'); + } + return query.executeTakeFirst(); + } + //////// + //// get page permission start with user, not group. + + async getUserPagePermission(opts: { + pageId: string; + userId: string; + }): Promise { + // Query traverses the page hierarchy and returns ALL permissions found at the closest level + // This handles cases where user has multiple permissions (direct + multiple groups) + // Returns all permissions regardless of cascade value - cascade check is done in the calling code + + // First, get the page hierarchy with levels + const pageHierarchy = await this.db + .withRecursive('page_hierarchy', (qb) => + qb + .selectFrom('pages') + .select(['id as pageId', 'parentPageId', sql`0`.as('level')]) + .where('id', '=', opts.pageId) + .where('deletedAt', 'is', null) + .unionAll((eb) => + eb + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.id', 'ph.parentPageId') + .select([ + 'p.id as pageId', + 'p.parentPageId', + sql`ph.level + 1`.as('level'), + ]) + .where('p.deletedAt', 'is', null), + ), + ) + .selectFrom('page_hierarchy') + .selectAll() + .orderBy('level', 'asc') + .execute(); + + // Check each level for permissions, starting from the current page + for (const page of pageHierarchy) { + const permissions = await this.db + .selectFrom('pagePermissions as pp') + .leftJoin('groupUsers as gu', (join) => + join + .onRef('gu.groupId', '=', 'pp.groupId') + .on('gu.userId', '=', opts.userId), + ) + .selectAll('pp') + .where('pp.pageId', '=', page.pageId) + .where('pp.deletedAt', 'is', null) + .where((eb) => + eb.or([ + eb('pp.userId', '=', opts.userId), + eb('gu.userId', '=', opts.userId), + ]), + ) + .execute(); + + // If we found permissions at this level, return them all + if (permissions.length > 0) { + return permissions; + } + } + + // No permissions found in the hierarchy + return []; + } + + /////// + + async removePageMemberById( + memberId: string, + pageId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('pagePermissions') + .where('id', '=', memberId) + .where('pageId', '=', pageId) + .execute(); + } + + async roleCountByPageId(role: string, pageId: string): Promise { + const { count } = await this.db + .selectFrom('pagePermissions') + .select((eb) => eb.fn.count('role').as('count')) + .where('role', '=', role) + .where('pageId', '=', pageId) + .executeTakeFirst(); + + return count as number; + } + + async getPageMembers(pageId: string): Promise { + return await this.db + .selectFrom('pagePermissions') + .selectAll() + .where('pageId', '=', pageId) + .where('deletedAt', 'is', null) + .execute(); + } + + async getPageMembersPaginated(pageId: string, pagination: PaginationOptions) { + let query = this.db + .selectFrom('pagePermissions') + .leftJoin('users', 'users.id', 'pagePermissions.userId') + .leftJoin('groups', 'groups.id', 'pagePermissions.groupId') + .select([ + 'pagePermissions.id', + 'users.id as userId', + 'users.name as userName', + 'users.avatarUrl as userAvatarUrl', + 'users.email as userEmail', + 'groups.id as groupId', + 'groups.name as groupName', + 'groups.isDefault as groupIsDefault', + 'pagePermissions.role', + 'pagePermissions.createdAt', + ]) + .select((eb) => this.groupRepo.withMemberCount(eb)) + .where('pageId', '=', pageId) + .orderBy((eb) => eb('groups.id', 'is not', null), 'desc') + .orderBy('pagePermissions.createdAt', 'asc'); + + if (pagination.query) { + query = query.where((eb) => + eb( + sql`f_unaccent(users.name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ) + .or( + sql`users.email`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ) + .or( + sql`f_unaccent(groups.name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ), + ); + } + + const result = await executeWithPagination(query, { + page: pagination.page, + perPage: pagination.limit, + }); + + let memberInfo: MemberInfo; + + const members = result.items.map((member) => { + if (member.userId) { + memberInfo = { + id: member.userId, + name: member.userName, + email: member.userEmail, + avatarUrl: member.userAvatarUrl, + type: 'user', + }; + } else if (member.groupId) { + memberInfo = { + id: member.groupId, + name: member.groupName, + memberCount: member.memberCount as number, + isDefault: member.groupIsDefault, + type: 'group', + }; + } + + return { + id: member.id, + ...memberInfo, + role: member.role, + createdAt: member.createdAt, + }; + }); + + result.items = members as any; + + return result; + } + + async getUserPageRoles( + userId: string, + pageId: string, + ): Promise { + const roles = await this.db + .selectFrom('pagePermissions') + .select(['userId', 'role']) + .where('userId', '=', userId) + .where('pageId', '=', pageId) + .where('deletedAt', 'is', null) + .unionAll( + this.db + .selectFrom('pagePermissions') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'pagePermissions.groupId', + ) + .select(['groupUsers.userId', 'pagePermissions.role']) + .where('groupUsers.userId', '=', userId) + .where('pagePermissions.pageId', '=', pageId) + .where('pagePermissions.deletedAt', 'is', null), + ) + .execute(); + + if (!roles || roles.length === 0) { + return undefined; + } + return roles; + } + + async resolveUserPageAccess( + userId: string, + pageId: string, + ): Promise { + // Use batch method for efficiency - single page is just a batch of 1 + const accessMap = await this.resolveUserPageAccessBatch(userId, [pageId]); + return accessMap.get(pageId) || null; + } + + async resolveUserPageAccessBatch( + userId: string, + pageIds: string[], + ): Promise> { + if (pageIds.length === 0) { + return new Map(); + } + + // Get all pages and their complete ancestor chains using recursive CTE + const pagesWithAncestors = await this.db + .withRecursive('page_tree', (qb) => + qb + .selectFrom('pages') + .select([ + 'id', + 'parentPageId', + // 'hasCustomPermissions', + // 'inheritPermissions', + ]) + .where('id', 'in', pageIds) + .unionAll((eb) => + eb + .selectFrom('pages as p') + .innerJoin('page_tree as pt', 'p.id', 'pt.parentPageId') + .select([ + 'p.id', + 'p.parentPageId', + //'p.hasCustomPermissions', + //'p.inheritPermissions', + ]) + .where('p.deletedAt', 'is', null), + ), + ) + .selectFrom('page_tree') + .selectAll() + .execute(); + + console.log('pages', pagesWithAncestors); + + // Build page hierarchy map + const pageMap = new Map(pagesWithAncestors.map((p) => [p.id, p])); + const allPageIds = Array.from(pageMap.keys()); + + // Get ALL permissions (including ancestors) for user in ONE query + const allPermissions = await this.db + .selectFrom('pagePermissions as pm') + .leftJoin('groupUsers as gu', (join) => + join + .on('gu.userId', '=', userId) + .onRef('gu.groupId', '=', 'pm.groupId'), + ) + .select(['pm.pageId', 'pm.role']) + .where('pm.pageId', 'in', allPageIds) // Include ancestor pages + .where('pm.deletedAt', 'is', null) + .where((eb) => + eb.or([eb('pm.userId', '=', userId), eb('gu.userId', '=', userId)]), + ) + .execute(); + + console.log('all permissions', allPermissions); + + // Build permission map + const permissionMap = new Map(); + allPermissions.forEach((p) => { + if (!permissionMap.has(p.pageId)) { + permissionMap.set(p.pageId, []); + } + permissionMap.get(p.pageId).push(p.role); + }); + + // Process each requested page + const accessMap = new Map(); + + for (const pageId of pageIds) { + const page = pageMap.get(pageId); + if (!page) { + accessMap.set(pageId, null); + continue; + } + + // Build ancestor chain + const ancestorChain: string[] = []; + let current = page; + while (current?.parentPageId) { + ancestorChain.push(current.parentPageId); + current = pageMap.get(current.parentPageId); + } + + // Check for ancestor NONE + let hasAncestorDenial = false; + for (const ancestorId of ancestorChain) { + const ancestorRoles = permissionMap.get(ancestorId) || []; + if (ancestorRoles.includes(PageMemberRole.NONE)) { + hasAncestorDenial = true; + break; + } + } + + const pageRoles = permissionMap.get(pageId) || []; + + // Apply cascade logic + if (hasAncestorDenial) { + if (!pageRoles.length) { + accessMap.set(pageId, PageMemberRole.NONE); // Inherit denial + } else if (pageRoles.includes(PageMemberRole.NONE)) { + accessMap.set(pageId, PageMemberRole.NONE); // Explicit denial + } else { + // Override with explicit permission + accessMap.set(pageId, this.findHighestRoleFromStrings(pageRoles)); + } + } else { + // No ancestor denial + if (pageRoles.includes(PageMemberRole.NONE)) { + accessMap.set(pageId, PageMemberRole.NONE); + } else if (pageRoles.length > 0) { + accessMap.set(pageId, this.findHighestRoleFromStrings(pageRoles)); + } else { + accessMap.set(pageId, null); + } + } + } + + return accessMap; + } + + async getUserPageIds(userId: string): Promise { + const membership = await this.db + .selectFrom('pagePermissions') + .innerJoin('pages', 'pages.id', 'pagePermissions.pageId') + .select(['pages.id']) + .where('userId', '=', userId) + .where('pagePermissions.role', '!=', PageMemberRole.NONE) + .where('pagePermissions.deletedAt', 'is', null) + .union( + this.db + .selectFrom('pagePermissions') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'pagePermissions.groupId', + ) + .innerJoin('pages', 'pages.id', 'pagePermissions.pageId') + .select(['pages.id']) + .where('groupUsers.userId', '=', userId) + .where('pagePermissions.role', '!=', PageMemberRole.NONE) + .where('pagePermissions.deletedAt', 'is', null), + ) + .execute(); + + return membership.map((page) => page.id); + } + + async getUserAccessiblePageIds( + userId: string, + spaceId: string, + pageIds: string[], + ): Promise> { + // Single query to get all page permissions for user + const accessiblePages = await this.db + .selectFrom('pages as p') + .leftJoin('pagePermissions as pm', 'pm.pageId', 'p.id') + .leftJoin('groupUsers as gu', (join) => + join + .on('gu.userId', '=', userId) + .onRef('gu.groupId', '=', 'pm.groupId'), + ) + .select([ + 'p.id', + // 'p.hasCustomPermissions', + //'p.inheritPermissions', + 'pm.role', + ]) + .where('p.id', 'in', pageIds) + .where('p.spaceId', '=', spaceId) + .where((eb) => + eb.or([ + // Pages without custom permissions (inherit from space) + // eb('p.hasCustomPermissions', '=', false), + // Pages with custom permissions where user has direct access + eb.and([ + // eb('p.hasCustomPermissions', '=', true), + eb('pm.userId', '=', userId), + eb('pm.role', '!=', PageMemberRole.NONE), + eb('pm.deletedAt', 'is', null), + ]), + // Pages with custom permissions where user has group access + eb.and([ + // eb('p.hasCustomPermissions', '=', true), + eb('gu.userId', '=', userId), + eb('pm.role', '!=', PageMemberRole.NONE), + eb('pm.deletedAt', 'is', null), + ]), + // Pages that inherit and user has space access (checked separately) + eb.and([ + // eb('p.hasCustomPermissions', '=', true), + // eb('p.inheritPermissions', '=', true), + ]), + ]), + ) + .execute(); + + // Also need to exclude pages where user has explicit "none" role + const blockedPageIds = await this.db + .selectFrom('pagePermissions as pm') + .leftJoin('groupUsers as gu', (join) => + join + .on('gu.userId', '=', userId) + .onRef('gu.groupId', '=', 'pm.groupId'), + ) + .select('pm.pageId') + .where('pm.pageId', 'in', pageIds) + .where('pm.role', '=', PageMemberRole.NONE) + .where('pm.deletedAt', 'is', null) + .where((eb) => + eb.or([eb('pm.userId', '=', userId), eb('gu.userId', '=', userId)]), + ) + .execute(); + + const blockedSet = new Set(blockedPageIds.map((p) => p.pageId)); + return new Set( + accessiblePages.filter((p) => !blockedSet.has(p.id)).map((p) => p.id), + ); + } + + private findHighestRole(roles: UserPageRole[]): string | null { + if (!roles || roles.length === 0) { + return null; + } + + const roleValues = roles.map((r) => r.role); + return this.findHighestRoleFromStrings(roleValues); + } + + private findHighestRoleFromStrings(roles: string[]): string | null { + if (roles.includes(PageMemberRole.ADMIN)) { + return PageMemberRole.ADMIN; + } + if (roles.includes(PageMemberRole.WRITER)) { + return PageMemberRole.WRITER; + } + if (roles.includes(PageMemberRole.READER)) { + return PageMemberRole.READER; + } + return null; + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index c814240a..1726a274 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -22,24 +22,6 @@ export class PageRepo { private spaceMemberRepo: SpaceMemberRepo, ) {} - withHasChildren(eb: ExpressionBuilder) { - return eb - .selectFrom('pages as child') - .select((eb) => - eb - .case() - .when(eb.fn.countAll(), '>', 0) - .then(true) - .else(false) - .end() - .as('count'), - ) - .whereRef('child.parentPageId', '=', 'pages.id') - .where('child.deletedAt', 'is', null) - .limit(1) - .as('hasChildren'); - } - private baseFields: Array = [ 'id', 'slugId', @@ -379,6 +361,24 @@ export class PageRepo { ).as('contributors'); } + withHasChildren(eb: ExpressionBuilder) { + return eb + .selectFrom('pages as child') + .select((eb) => + eb + .case() + .when(eb.fn.countAll(), '>', 0) + .then(true) + .else(false) + .end() + .as('count'), + ) + .whereRef('child.parentPageId', '=', 'pages.id') + .where('child.deletedAt', 'is', null) + .limit(1) + .as('hasChildren'); + } + async getPageAndDescendants( parentPageId: string, opts: { includeContent: boolean }, @@ -420,4 +420,46 @@ export class PageRepo { .selectAll() .execute(); } + + async update( + pageId: string, + updatablePage: UpdatablePage, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('pages') + .set({ ...updatablePage, updatedAt: new Date() }) + .where('id', '=', pageId) + .execute(); + } + + async getAllDescendants( + pageId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + // Recursive CTE to get all descendants + const descendants = await db + .withRecursive('page_tree', (qb) => + qb + .selectFrom('pages') + .select(['id', 'parentPageId']) + .where('parentPageId', '=', pageId) + .where('deletedAt', 'is', null) + .unionAll((eb) => + eb + .selectFrom('pages as p') + .innerJoin('page_tree as pt', 'p.parentPageId', 'pt.id') + .select(['p.id', 'p.parentPageId']) + .where('p.deletedAt', 'is', null), + ), + ) + .selectFrom('page_tree') + .select('id') + .execute(); + + return descendants.map((d) => d.id); + } } diff --git a/apps/server/src/database/repos/page/shared-pages.repo.ts b/apps/server/src/database/repos/page/shared-pages.repo.ts new file mode 100644 index 00000000..7225f70d --- /dev/null +++ b/apps/server/src/database/repos/page/shared-pages.repo.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '../../types/kysely.types'; +import { Page } from '../../types/entity.types'; +import { PageMemberRole } from './page-permission-repo.service'; + +@Injectable() +export class SharedPagesRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async addSharedPage(userId: string, pageId: string): Promise { + await this.db + .insertInto('userSharedPages') + .values({ + userId, + pageId, + sharedAt: new Date(), + }) + .onConflict((oc) => oc.columns(['userId', 'pageId']).doNothing()) + .execute(); + } + + async removeSharedPage(userId: string, pageId: string): Promise { + await this.db + .deleteFrom('userSharedPages') + .where('userId', '=', userId) + .where('pageId', '=', pageId) + .execute(); + } + + async getUserSharedPages(userId: string): Promise { + return await this.db + .selectFrom('userSharedPages as usp') + .innerJoin('pages as p', 'p.id', 'usp.pageId') + .innerJoin('pagePermissions as pm', (join) => + join + .onRef('pm.pageId', '=', 'p.id') + .on('pm.userId', '=', userId) + .on('pm.role', '!=', PageMemberRole.NONE), + ) + .selectAll('p') + .where('usp.userId', '=', userId) + .where('p.deletedAt', 'is', null) + .orderBy('usp.sharedAt', 'desc') + .execute(); + } + + async isPageSharedWithUser(userId: string, pageId: string): Promise { + const result = await this.db + .selectFrom('userSharedPages') + .select('userId') + .where('userId', '=', userId) + .where('pageId', '=', pageId) + .executeTakeFirst(); + + return !!result; + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index e8662649..2ac2bffa 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -5,8 +5,6 @@ import type { ColumnType } from "kysely"; -export type AuthProviderType = "google" | "oidc" | "saml"; - export type Generated = T extends ColumnType ? ColumnType : ColumnType; @@ -62,13 +60,21 @@ export interface AuthProviders { deletedAt: Timestamp | null; id: Generated; isEnabled: Generated; + ldapBaseDn: string | null; + ldapBindDn: string | null; + ldapBindPassword: string | null; + ldapTlsCaCert: string | null; + ldapTlsEnabled: Generated; + ldapUrl: string | null; + ldapUserAttributes: Json | null; + ldapUserSearchFilter: string | null; name: string; oidcClientId: string | null; oidcClientSecret: string | null; oidcIssuer: string | null; samlCertificate: string | null; samlUrl: string | null; - type: AuthProviderType; + type: string; updatedAt: Generated; workspaceId: string; } @@ -186,6 +192,19 @@ export interface PageHistory { workspaceId: string; } +export interface PagePermissions { + addedById: string | null; + cascade: Generated; + createdAt: Generated; + deletedAt: Timestamp | null; + groupId: string | null; + id: Generated; + pageId: string; + role: string; + updatedAt: Generated; + userId: string | null; +} + export interface Pages { content: Json | null; contributorIds: Generated; @@ -284,6 +303,12 @@ export interface Users { workspaceId: string | null; } +export interface UserSharedPages { + pageId: string; + sharedAt: Generated; + userId: string; +} + export interface UserTokens { createdAt: Generated; expiresAt: Timestamp | null; @@ -342,12 +367,14 @@ export interface DB { groups: Groups; groupUsers: GroupUsers; pageHistory: PageHistory; + pagePermissions: PagePermissions; pages: Pages; shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; userMfa: UserMfa; users: Users; + userSharedPages: UserSharedPages; userTokens: UserTokens; workspaceInvitations: WorkspaceInvitations; workspaces: Workspaces; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index b23fa775..06f2e047 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -4,8 +4,10 @@ import { Comments, Groups, Pages, + PagePermissions, Spaces, Users, + UserSharedPages, Workspaces, PageHistory as History, GroupUsers, @@ -48,6 +50,15 @@ export type SpaceMember = Selectable; export type InsertableSpaceMember = Insertable; export type UpdatableSpaceMember = Updateable>; +// PageMember +export type PagePermission = Selectable; +export type InsertablePagePermission = Insertable; +export type UpdatablePagePermission = Updateable>; + +// UserSharedPage +export type UserSharedPage = Selectable; +export type InsertableUserSharedPage = Insertable; + // Group export type ExtendedGroup = Groups & { memberCount: number }; diff --git a/apps/server/src/ee b/apps/server/src/ee index 72bdb639..fbc01d80 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 72bdb639cf5e7ddb065f339111d53807cff7e006 +Subproject commit fbc01d808f3edd7d16a64c21251a0bcb720f1ba4