diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index 23149288..79a62ab6 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -1,3 +1,3 @@ export enum EventName { COLLAB_PAGE_UPDATED = 'collab.page.updated', -} \ No newline at end of file +} diff --git a/apps/server/src/common/helpers/nanoid.utils.ts b/apps/server/src/common/helpers/nanoid.utils.ts index 71234659..032949c2 100644 --- a/apps/server/src/common/helpers/nanoid.utils.ts +++ b/apps/server/src/common/helpers/nanoid.utils.ts @@ -5,4 +5,4 @@ export const nanoIdGen = customAlphabet(alphabet, 10); const slugIdAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; -export const generateSlugId = customAlphabet(slugIdAlphabet, 10); \ No newline at end of file +export const generateSlugId = customAlphabet(slugIdAlphabet, 10); 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/common/logger/internal-log-filter.ts b/apps/server/src/common/logger/internal-log-filter.ts index 5273716c..ae63512a 100644 --- a/apps/server/src/common/logger/internal-log-filter.ts +++ b/apps/server/src/common/logger/internal-log-filter.ts @@ -14,11 +14,18 @@ export class InternalLogFilter extends ConsoleLogger { super(); const isProduction = process.env.NODE_ENV === 'production'; const isDebugMode = process.env.DEBUG_MODE === 'true'; - + if (isProduction && !isDebugMode) { this.allowedLogLevels = ['log', 'error', 'fatal']; } else { - this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal']; + this.allowedLogLevels = [ + 'log', + 'debug', + 'verbose', + 'warn', + 'error', + 'fatal', + ]; } } diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index f1c4c5c0..15d37440 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -77,10 +77,7 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn: '1h' }); } - async generateMfaToken( - user: User, - workspaceId: string, - ): Promise { + async generateMfaToken(user: User, workspaceId: string): Promise { if (user.deactivatedAt || user.deletedAt) { throw new ForbiddenException(); } 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..a9fca6e4 --- /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/group/dto/create-group.dto.ts b/apps/server/src/core/group/dto/create-group.dto.ts index 2efdad35..356500a5 100644 --- a/apps/server/src/core/group/dto/create-group.dto.ts +++ b/apps/server/src/core/group/dto/create-group.dto.ts @@ -7,7 +7,7 @@ import { MaxLength, MinLength, } from 'class-validator'; -import {Transform, TransformFnParams} from "class-transformer"; +import { Transform, TransformFnParams } from 'class-transformer'; export class CreateGroupDto { @MinLength(2) 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..981c38d3 --- /dev/null +++ b/apps/server/src/core/page/dto/add-page-members.dto.ts @@ -0,0 +1,34 @@ +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 +} diff --git a/apps/server/src/core/page/dto/deleted-page.dto.ts b/apps/server/src/core/page/dto/deleted-page.dto.ts index 8c458cc4..d79016ca 100644 --- a/apps/server/src/core/page/dto/deleted-page.dto.ts +++ b/apps/server/src/core/page/dto/deleted-page.dto.ts @@ -4,4 +4,4 @@ export class DeletedPageDto { @IsNotEmpty() @IsString() spaceId: string; -} \ No newline at end of file +} diff --git a/apps/server/src/core/page/dto/duplicate-page.dto.ts b/apps/server/src/core/page/dto/duplicate-page.dto.ts index 395ad9a3..07e46cad 100644 --- a/apps/server/src/core/page/dto/duplicate-page.dto.ts +++ b/apps/server/src/core/page/dto/duplicate-page.dto.ts @@ -17,8 +17,8 @@ export type CopyPageMapEntry = { }; export type ICopyPageAttachment = { - newPageId: string, - oldPageId: string, - oldAttachmentId: string, - newAttachmentId: string, + newPageId: string; + oldPageId: string; + oldAttachmentId: string; + newAttachmentId: string; }; 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..b3b0187b --- /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; +} 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..0d8d1cdd --- /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; +} 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..38cc512a --- /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; +} 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..2c951df2 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,162 @@ export class PageController { } return this.pageService.getPageBreadCrumbs(page.id); } + + @HttpCode(HttpStatus.OK) + @Post('permissions/restrict') + async restrictPage(@Body() dto: PageIdDto, @AuthUser() user: User) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + // TODO: make sure they have access to the page, and can restrict + // And the page is not already restricted + // They can add and remove page restriction + // When a page restriction is removed, we remove the entries in page permissions table. + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pagePermissionService.restrictPage(user, 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..7b25dd25 --- /dev/null +++ b/apps/server/src/core/page/services/page-member.service.ts @@ -0,0 +1,648 @@ +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 restrictPage(authUser: User, pageId: string) { + // to add custom permissions to a page, + // we have to restrict the page first. + // the user is here because they can restrict this page + // TODO: make sure page is not in trash + // Not sure if normal users can see restricted pages in trash. + await this.db + .updateTable('pages') + .set({ + isRestricted: true, + restrictedById: authUser.id, + }) + .where('id', '=', pageId) + .execute(); + } + + 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/core/space/dto/create-space.dto.ts b/apps/server/src/core/space/dto/create-space.dto.ts index bd7e6689..da0d8b4b 100644 --- a/apps/server/src/core/space/dto/create-space.dto.ts +++ b/apps/server/src/core/space/dto/create-space.dto.ts @@ -5,7 +5,7 @@ import { MaxLength, MinLength, } from 'class-validator'; -import {Transform, TransformFnParams} from "class-transformer"; +import { Transform, TransformFnParams } from 'class-transformer'; export class CreateSpaceDto { @MinLength(2) diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index f71c85f1..1e4db229 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -70,7 +70,9 @@ export class UserService { ); if (!isPasswordMatch) { - throw new BadRequestException('You must provide the correct password to change your email'); + throw new BadRequestException( + 'You must provide the correct password to change your email', + ); } if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) { 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/20250327T145832-add-contributorIds-to-pages.ts b/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts index ecad09d8..ceb968a5 100644 --- a/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts +++ b/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts @@ -3,7 +3,7 @@ import { type Kysely, sql } from 'kysely'; export async function up(db: Kysely): Promise { await db.schema .alterTable('pages') - .addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}")) + .addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}')) .execute(); } 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..0b77b1b6 --- /dev/null +++ b/apps/server/src/database/migrations/20250812T000805-add-page-permissions.ts @@ -0,0 +1,102 @@ +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(); + + await db.schema + .alterTable('pages') + .addColumn('is_restricted', 'boolean', (col) => + col.defaultTo(false).notNull(), + ) + .addColumn('restricted_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set 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 { + await db.schema.alterTable('pages').dropColumn('is_restricted').execute(); + await db.schema.alterTable('pages').dropColumn('restricted_by_id').execute(); + + 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/comment/comment.repo.ts b/apps/server/src/database/repos/comment/comment.repo.ts index 965bd611..018479ba 100644 --- a/apps/server/src/database/repos/comment/comment.repo.ts +++ b/apps/server/src/database/repos/comment/comment.repo.ts @@ -105,7 +105,10 @@ export class CommentRepo { return Number(result?.count) > 0; } - async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise { + async hasChildrenFromOtherUsers( + commentId: string, + userId: string, + ): Promise { const result = await this.db .selectFrom('comments') .select((eb) => eb.fn.count('id').as('count')) diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts index 5c144ec4..76c973ae 100644 --- a/apps/server/src/database/repos/group/group-user.repo.ts +++ b/apps/server/src/database/repos/group/group-user.repo.ts @@ -57,7 +57,11 @@ export class GroupUserRepo { if (pagination.query) { query = query.where((eb) => - eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`), + eb( + sql`f_unaccent(users.name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ), ); } diff --git a/apps/server/src/database/repos/group/group.repo.ts b/apps/server/src/database/repos/group/group.repo.ts index 6d0e4257..2545f6db 100644 --- a/apps/server/src/database/repos/group/group.repo.ts +++ b/apps/server/src/database/repos/group/group.repo.ts @@ -114,7 +114,11 @@ export class GroupRepo { if (pagination.query) { query = query.where((eb) => - eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( + eb( + sql`f_unaccent(name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ).or( sql`f_unaccent(description)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`, 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..7793be4c --- /dev/null +++ b/apps/server/src/database/repos/page/page-permission-repo.service.ts @@ -0,0 +1,1041 @@ +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. + + /** + * Determines if a user has access to a specific page considering page-level restrictions. + * + * This method implements a hierarchical permission model similar to Atlassian Confluence: + * 1. Traverses the page hierarchy from target page to root + * 2. Identifies the closest restricted ancestor (or the page itself if restricted) + * 3. Checks user permissions (direct or via groups) on the restricted page + * 4. Applies cascade rules for inherited permissions + * + * @param opts.pageId - The ID of the page to check access for + * @param opts.userId - The ID of the user requesting access + * + * @returns Object containing: + * - hasAccess: Whether the user can access the page + * - role: The highest role granted to the user (admin > writer > reader) + * - isRestricted: Whether the page or any ancestor has restrictions + * - restrictedPageId/Title: Information about the restricting page + * - inheritedFrom: If permission is inherited, the ID of the source page + * - permissions: Detailed list of all applicable permissions + * + * @example + * // Check if user can access a page + * const access = await repo.getUserPagePermission({ + * pageId: 'page-123', + * userId: 'user-456' + * }); + * if (access.hasAccess) { + * console.log(`User has ${access.role} access`); + * } + */ + async getUserPagePermission(opts: { + pageId: string; + userId: string; + }): Promise<{ + hasAccess: boolean; + role?: string; + restrictedPageId?: string; + restrictedPageTitle?: string; + isRestricted: boolean; + isInherited?: boolean; + inheritedFrom?: string; + permissions?: Array<{ + role: string; + source: 'direct' | 'group'; + groupId?: string; + cascade?: boolean; + }>; + }> { + // TODO: to + // first we have to check if the page is restricted and by whom + // we have to check both the page and all its ancestors if they have the is_restricted permission. + // if the page is not restricted directly or by its ancestors, we return access to the page and fall back to the space level permission. + + //If the page inherits permission from an ancestor, + // we have to get the id and info of that ancestor. + //then we want the code below, check if the userId, has access to that permissioned pageId, either directly or via a group. + // then we return all the permissions, so we can select the highest role to grant the user access with in js + // if not, then we return faled access. they dont have access + + // Performance-optimized implementation using a single query with recursive CTEs + // This approach minimizes database round-trips which is critical for permission checks + + try { + const result = await this.db + .withRecursive('page_hierarchy', (qb) => + qb + // Start with the target page + .selectFrom('pages') + .select([ + 'id', + 'parentPageId', + 'title', + 'isRestricted', + 'restrictedById', + sql`0`.as('level'), + sql`id`.as('originalPageId'), + ]) + .where('id', '=', opts.pageId) + .where('deletedAt', 'is', null) + .unionAll((eb) => + // Recursively traverse up the page tree + eb + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.id', 'ph.parentPageId') + .select([ + 'p.id', + 'p.parentPageId', + 'p.title', + 'p.isRestricted', + 'p.restrictedById', + sql`ph.level + 1`.as('level'), + 'ph.originalPageId', + ]) + .where('p.deletedAt', 'is', null), + ), + ) + .with('restricted_page', (qb) => + // Find the first restricted page in the hierarchy (lowest level = closest to target) + qb + .selectFrom('page_hierarchy') + .selectAll() + .where('isRestricted', '=', true) + .orderBy('level', 'asc') + .limit(1), + ) + .with('user_permissions', (qb) => + // Get all permissions for the user on the restricted page (direct + group-based) + qb + .selectFrom('restricted_page as rp') + .leftJoin('pagePermissions as pp', 'pp.pageId', 'rp.id') + .leftJoin('groupUsers as gu', (join) => + join + .onRef('gu.groupId', '=', 'pp.groupId') + .on('gu.userId', '=', opts.userId), + ) + .select([ + 'pp.role', + 'pp.cascade', + 'pp.userId', + 'pp.groupId', + 'rp.id as restrictedPageId', + 'rp.title as restrictedPageTitle', + 'rp.level', + sql`CASE + WHEN pp.user_id IS NOT NULL THEN 'direct' + WHEN gu.user_id IS NOT NULL THEN 'group' + ELSE NULL + END`.as('source'), + ]) + .where('pp.deletedAt', 'is', null) + .where((eb) => + eb.or([ + eb('pp.userId', '=', opts.userId), + eb('gu.userId', '=', opts.userId), + ]), + ), + ) + // Final query - combine CTEs to get all relevant data + .selectFrom('page_hierarchy as ph') + .leftJoin( + 'restricted_page as rp', + (join) => join.onRef('ph.id', '=', 'ph.id'), // Self-join to include all rows + ) + .leftJoin('user_permissions as up', (join) => + join.onRef('rp.id', '=', 'up.restrictedPageId'), + ) + .select([ + 'ph.id', + 'ph.isRestricted', + 'rp.id as restrictedPageId', + 'rp.title as restrictedPageTitle', + 'rp.level as restrictedLevel', + 'up.role', + 'up.source', + 'up.groupId', + 'up.cascade', + ]) + .where('ph.level', '=', 0) // Only get the original page + .execute(); + + console.log(result); + + if (!result || result.length === 0) { + // Page not found or deleted + return { + hasAccess: false, + isRestricted: false, + }; + } + + const pageData = result[0]; + + // If no restricted page found in hierarchy, access is allowed (falls back to space permissions) + if (!pageData.restrictedPageId) { + return { + hasAccess: true, + isRestricted: false, + }; + } + + // Collect all permissions from the result + const permissions = result + .filter((r) => r.role && r.source) + .map((r) => ({ + role: r.role, + source: r.source as 'direct' | 'group', + groupId: r.groupId || undefined, + cascade: r.cascade || false, + })); + + // If page is restricted but user has no permissions + if (permissions.length === 0) { + const isInheritedPermission = pageData.restrictedPageId !== opts.pageId; + return { + hasAccess: false, + isRestricted: true, + isInherited: isInheritedPermission, + restrictedPageId: pageData.restrictedPageId, + restrictedPageTitle: pageData.restrictedPageTitle, + inheritedFrom: isInheritedPermission ? pageData.restrictedPageId : undefined, + }; + } + + // Check for explicit denial (NONE role) + const hasDenial = permissions.some((p) => p.role === PageMemberRole.NONE); + if (hasDenial) { + const isInheritedPermission = pageData.restrictedPageId !== opts.pageId; + return { + hasAccess: false, + role: PageMemberRole.NONE, + isRestricted: true, + isInherited: isInheritedPermission, + restrictedPageId: pageData.restrictedPageId, + restrictedPageTitle: pageData.restrictedPageTitle, + inheritedFrom: isInheritedPermission ? pageData.restrictedPageId : undefined, + permissions, + }; + } + + // Find highest role + const highestRole = this.findHighestRoleFromStrings( + permissions.map((p) => p.role), + ); + + // Check if permission cascades to child pages + const canCascade = result.some((r) => r.cascade === true); + const isInheritedPermission = pageData.restrictedPageId !== opts.pageId; + + // Access is granted if: + // 1. Page itself is restricted and user has permission, OR + // 2. Ancestor is restricted, user has permission, AND cascade is enabled + return { + hasAccess: !isInheritedPermission || canCascade, + role: highestRole, + isRestricted: true, + isInherited: isInheritedPermission, + restrictedPageId: pageData.restrictedPageId, + restrictedPageTitle: pageData.restrictedPageTitle, + inheritedFrom: isInheritedPermission + ? pageData.restrictedPageId + : undefined, + permissions, + }; + } catch (error) { + // Log error for monitoring but don't expose internal details + console.error('Error checking page permissions:', error); + // Fail closed for security - deny access on error + return { + hasAccess: false, + isRestricted: false, + }; + } + + + } + + async getUserPagePermissionAlternative(opts: { + pageId: string; + userId: string; + }): Promise<{ + hasAccess: boolean; + role?: string; + restrictedPageId?: string; + restrictedPageTitle?: string; + isRestricted: boolean; + inheritedFrom?: string; + permissions?: Array<{ + role: string; + source: 'direct' | 'group'; + groupId?: string; + }>; + }> { + // Solution 2: Step-by-step approach with clearer logic flow + // This approach trades some performance for improved maintainability and debuggability + + // Step 1: Build the page hierarchy from target page to root + const pageHierarchy = await this.db + .withRecursive('page_tree', (qb) => + qb + .selectFrom('pages') + .select([ + 'id', + 'parentPageId', + 'title', + 'isRestricted', + 'restrictedById', + ]) + .where('id', '=', opts.pageId) + .where('deletedAt', 'is', null) + .unionAll((eb) => + eb + .selectFrom('pages as p') + .innerJoin('page_tree as pt', 'p.id', 'pt.parentPageId') + .select([ + 'p.id', + 'p.parentPageId', + 'p.title', + 'p.isRestricted', + 'p.restrictedById', + ]) + .where('p.deletedAt', 'is', null), + ), + ) + .selectFrom('page_tree') + .selectAll() + .execute(); + + if (!pageHierarchy || pageHierarchy.length === 0) { + return { + hasAccess: false, + isRestricted: false, + }; + } + + // Step 2: Find the closest restricted page (starting from target, moving up) + const targetPage = pageHierarchy.find((p) => p.id === opts.pageId); + let restrictedPage = null; + + // Build ordered ancestor chain + const ancestorChain: typeof pageHierarchy = []; + let currentPage = targetPage; + + while (currentPage) { + ancestorChain.push(currentPage); + if (currentPage.isRestricted && !restrictedPage) { + restrictedPage = currentPage; + } + currentPage = pageHierarchy.find( + (p) => p.id === currentPage.parentPageId, + ); + } + + // Step 3: If no restriction found, grant access (falls back to space permissions) + if (!restrictedPage) { + return { + hasAccess: true, + isRestricted: false, + }; + } + + // Step 4: Get user's groups for permission checking + const userGroups = await this.db + .selectFrom('groupUsers') + .select('groupId') + .where('userId', '=', opts.userId) + .execute(); + + const userGroupIds = userGroups.map((g) => g.groupId); + + // Step 5: Fetch all permissions for the restricted page + const pagePermissions = await this.db + .selectFrom('pagePermissions') + .leftJoin('groups', 'groups.id', 'pagePermissions.groupId') + .selectAll('pagePermissions') + .select(['groups.name as groupName']) + .where('pagePermissions.pageId', '=', restrictedPage.id) + .where('pagePermissions.deletedAt', 'is', null) + .where((eb) => + eb.or([ + eb('pagePermissions.userId', '=', opts.userId), + ...(userGroupIds.length > 0 + ? [eb('pagePermissions.groupId', 'in', userGroupIds)] + : []), + ]), + ) + .execute(); + + // Step 6: Process permissions + const permissions = pagePermissions.map((p) => ({ + role: p.role, + source: + p.userId === opts.userId ? 'direct' : ('group' as 'direct' | 'group'), + groupId: p.groupId || undefined, + cascade: p.cascade, + })); + + // Step 7: Check for access denial + if (permissions.length === 0) { + return { + hasAccess: false, + isRestricted: true, + restrictedPageId: restrictedPage.id, + restrictedPageTitle: restrictedPage.title, + }; + } + + // Check for explicit NONE role + const hasDenial = permissions.some((p) => p.role === PageMemberRole.NONE); + if (hasDenial) { + return { + hasAccess: false, + role: PageMemberRole.NONE, + isRestricted: true, + restrictedPageId: restrictedPage.id, + restrictedPageTitle: restrictedPage.title, + permissions: permissions.map((p) => ({ + role: p.role, + source: p.source, + groupId: p.groupId, + })), + }; + } + + // Step 8: Determine access based on cascade setting + const highestRole = this.findHighestRoleFromStrings( + permissions.map((p) => p.role), + ); + const isInheritedPermission = restrictedPage.id !== opts.pageId; + + // Check if any permission allows cascading to children + const canAccessChildPages = permissions.some((p) => p.cascade === true); + + // If restriction is on an ancestor page, check cascade + if (isInheritedPermission && !canAccessChildPages) { + return { + hasAccess: false, + role: highestRole, + isRestricted: true, + restrictedPageId: restrictedPage.id, + restrictedPageTitle: restrictedPage.title, + inheritedFrom: restrictedPage.id, + permissions: permissions.map((p) => ({ + role: p.role, + source: p.source, + groupId: p.groupId, + })), + }; + } + + // Step 9: Grant access with the highest role + return { + hasAccess: true, + role: highestRole, + isRestricted: true, + restrictedPageId: restrictedPage.id, + restrictedPageTitle: restrictedPage.title, + inheritedFrom: isInheritedPermission ? restrictedPage.id : undefined, + permissions: permissions.map((p) => ({ + role: p.role, + source: p.source, + groupId: p.groupId, + })), + }; + } + + async getUserPagePermissionOld(opts: { + pageId: string; + userId: string; + }): Promise { + // TODO: to + // first we have to check if the page is restricted and by whom + // we have to check both the page and all its ancestors if they have the is_restricted permission. + // if the page is not restricted directly or by its ancestors, we return access to the page and fall back to the space level permission. + + //If the page inherits permission from an ancestor, + // we have to get the id and info of that ancestor. + //then we want the code below, check if the userId, has access to that permissioned pageId, either directly or via a group. + // then we return all the permissions, so we can select the highest role to grant the user access with in js + // if not, then we return faled access. they dont have access + + // Single optimized query that traverses the page hierarchy and returns ALL permissions at the closest level + // This handles cases where user has multiple permissions (direct + multiple groups) + // Uses window function to find minimum level, then filters to only return permissions at that level + + const permissions = 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), + ), + ) + .with('user_permissions', (qb) => + qb + .selectFrom('page_hierarchy as ph') + .innerJoin('pagePermissions as pp', 'pp.pageId', 'ph.pageId') + .leftJoin('groupUsers as gu', (join) => + join + .onRef('gu.groupId', '=', 'pp.groupId') + .on('gu.userId', '=', opts.userId), + ) + .selectAll('pp') + .select([ + 'ph.level', + sql`MIN(ph.level) OVER ()`.as('min_level'), + ]) + .where('pp.deletedAt', 'is', null) + .where((eb) => + eb.or([ + eb('pp.userId', '=', opts.userId), + eb('gu.userId', '=', opts.userId), + ]), + ), + ) + .selectFrom('user_permissions') + .selectAll() + .where((eb) => eb('level', '=', eb.ref('min_level'))) + .execute(); + + console.log(permissions); + + return permissions[0]; + } + + /////// + + 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/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index d92f9828..9c5dcb5d 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -110,7 +110,11 @@ export class SpaceRepo { if (pagination.query) { query = query.where((eb) => - eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( + eb( + sql`f_unaccent(name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ).or( sql`f_unaccent(description)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`, diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index e8662649..fcc08474 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -3,15 +3,18 @@ * Please do not edit it manually. */ -import type { ColumnType } from "kysely"; +import type { ColumnType } from 'kysely'; -export type AuthProviderType = "google" | "oidc" | "saml"; +export type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; - -export type Int8 = ColumnType; +export type Int8 = ColumnType< + string, + bigint | number | string, + bigint | number | string +>; export type Json = JsonValue; @@ -62,13 +65,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 +197,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; @@ -197,9 +221,11 @@ export interface Pages { icon: string | null; id: Generated; isLocked: Generated; + isRestricted: Generated; lastUpdatedById: string | null; parentPageId: string | null; position: string | null; + restrictedById: string | null; slugId: string; spaceId: string; textContent: string | null; @@ -284,6 +310,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 +374,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 diff --git a/apps/server/src/integrations/export/dto/export-dto.ts b/apps/server/src/integrations/export/dto/export-dto.ts index a2216b25..ffc383a1 100644 --- a/apps/server/src/integrations/export/dto/export-dto.ts +++ b/apps/server/src/integrations/export/dto/export-dto.ts @@ -41,4 +41,4 @@ export class ExportSpaceDto { @IsOptional() @IsBoolean() includeAttachments?: boolean; -} \ No newline at end of file +} diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index b8f3a201..1e39a471 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -107,7 +107,7 @@ export class ExportService { const page = await this.pageRepo.findById(pageId, { includeContent: true, }); - if (page){ + if (page) { pages = [page]; } } diff --git a/apps/server/src/integrations/export/turndown-utils.ts b/apps/server/src/integrations/export/turndown-utils.ts index b20e6733..69e2c9e4 100644 --- a/apps/server/src/integrations/export/turndown-utils.ts +++ b/apps/server/src/integrations/export/turndown-utils.ts @@ -69,17 +69,21 @@ function taskList(turndownService: TurndownService) { 'input[type="checkbox"]', ) as HTMLInputElement; const isChecked = checkbox.checked; - + // Process content like regular list items content = content .replace(/^\n+/, '') // remove leading newlines .replace(/\n+$/, '\n') // replace trailing newlines with just a single one .replace(/\n/gm, '\n '); // indent nested content with 2 spaces - + // Create the checkbox prefix const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; - - return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); + + return ( + prefix + + content + + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); }, }); } diff --git a/apps/server/src/integrations/import/dto/file-task-dto.ts b/apps/server/src/integrations/import/dto/file-task-dto.ts index 9cdea395..8562df26 100644 --- a/apps/server/src/integrations/import/dto/file-task-dto.ts +++ b/apps/server/src/integrations/import/dto/file-task-dto.ts @@ -15,4 +15,4 @@ export type ImportPageNode = { parentPageId: string | null; fileExtension: string; filePath: string; -}; \ No newline at end of file +}; diff --git a/apps/server/src/integrations/import/services/import-attachment.service.ts b/apps/server/src/integrations/import/services/import-attachment.service.ts index b9a488a9..5f327d4b 100644 --- a/apps/server/src/integrations/import/services/import-attachment.service.ts +++ b/apps/server/src/integrations/import/services/import-attachment.service.ts @@ -85,16 +85,18 @@ export class ImportAttachmentService { const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`; - attachmentTasks.push(() => this.uploadWithRetry({ - abs, - storageFilePath, - attachmentId, - fileNameWithExt, - ext, - pageId, - fileTask, - uploadStats, - })); + attachmentTasks.push(() => + this.uploadWithRetry({ + abs, + storageFilePath, + attachmentId, + fileNameWithExt, + ext, + pageId, + fileTask, + uploadStats, + }), + ); return { attachmentId, @@ -290,26 +292,26 @@ export class ImportAttachmentService { // wait for all uploads & DB inserts uploadStats.total = attachmentTasks.length; - + if (uploadStats.total > 0) { - this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`); - + this.logger.debug( + `Starting upload of ${uploadStats.total} attachments...`, + ); + try { - await Promise.all( - attachmentTasks.map(task => limit(task)) - ); + await Promise.all(attachmentTasks.map((task) => limit(task))); } catch (err) { this.logger.error('Import attachment upload error', err); } - + this.logger.debug( - `Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed` + `Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`, ); - + if (uploadStats.failed > 0) { this.logger.warn( `Failed to upload ${uploadStats.failed} files:`, - uploadStats.failedFiles + uploadStats.failedFiles, ); } } @@ -344,7 +346,7 @@ export class ImportAttachmentService { } = opts; let lastError: Error; - + for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { try { const fileStream = createReadStream(abs); @@ -367,35 +369,35 @@ export class ImportAttachmentService { spaceId: fileTask.spaceId, }) .execute(); - + uploadStats.completed++; - + if (uploadStats.completed % 10 === 0) { this.logger.debug( - `Upload progress: ${uploadStats.completed}/${uploadStats.total}` + `Upload progress: ${uploadStats.completed}/${uploadStats.total}`, ); } - + return; } catch (error) { lastError = error as Error; this.logger.warn( - `Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}` + `Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}`, ); - + if (attempt < this.MAX_RETRIES) { - await new Promise(resolve => - setTimeout(resolve, this.RETRY_DELAY * attempt) + await new Promise((resolve) => + setTimeout(resolve, this.RETRY_DELAY * attempt), ); } } } - + uploadStats.failed++; uploadStats.failedFiles.push(fileNameWithExt); this.logger.error( `Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`, - lastError + lastError, ); } } diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts index ce105f1c..05c04a5b 100644 --- a/apps/server/src/integrations/queue/constants/queue.interface.ts +++ b/apps/server/src/integrations/queue/constants/queue.interface.ts @@ -1,5 +1,4 @@ -import { MentionNode } from "../../../common/helpers/prosemirror/utils"; - +import { MentionNode } from '../../../common/helpers/prosemirror/utils'; export interface IPageBacklinkJob { pageId: string; @@ -9,4 +8,4 @@ export interface IPageBacklinkJob { export interface IStripeSeatsSyncJob { workspaceId: string; -} \ No newline at end of file +} diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index caa6ab36..c1b76c32 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -42,7 +42,7 @@ export class LocalDriver implements StorageDriver { try { const fromFullPath = this._fullPath(fromFilePath); const toFullPath = this._fullPath(toFilePath); - + if (await this.exists(fromFilePath)) { await fs.copy(fromFullPath, toFullPath); } diff --git a/apps/server/src/integrations/storage/providers/storage.provider.ts b/apps/server/src/integrations/storage/providers/storage.provider.ts index 11489666..c7bce811 100644 --- a/apps/server/src/integrations/storage/providers/storage.provider.ts +++ b/apps/server/src/integrations/storage/providers/storage.provider.ts @@ -40,8 +40,8 @@ export const storageDriverConfigProvider = { }, }; - case StorageOption.S3: - { const s3Config = { + case StorageOption.S3: { + const s3Config = { driver, config: { region: environmentService.getAwsS3Region(), @@ -68,7 +68,8 @@ export const storageDriverConfigProvider = { }; } - return s3Config; } + return s3Config; + } default: throw new Error(`Unknown storage driver: ${driver}`);