From 68a838606a14fda77d7830456ed7f807d14bdeb2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:41:29 +0000 Subject: [PATCH] WIP --- .../src/common/helpers/types/permission.ts | 9 + .../src/core/page/dto/page-permission.dto.ts | 67 +++ .../core/page/page-permission.controller.ts | 107 ++++ apps/server/src/core/page/page.module.ts | 8 +- .../page/services/page-permission.service.ts | 478 ++++++++++++++++++ apps/server/src/database/database.module.ts | 3 + .../20251223T130000-page-permissions.ts | 93 ++++ .../repos/page/page-permission.repo.ts | 270 ++++++++++ apps/server/src/database/types/db.d.ts | 23 + .../server/src/database/types/db.interface.ts | 4 + .../server/src/database/types/entity.types.ts | 12 + 11 files changed, 1071 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/core/page/dto/page-permission.dto.ts create mode 100644 apps/server/src/core/page/page-permission.controller.ts create mode 100644 apps/server/src/core/page/services/page-permission.service.ts create mode 100644 apps/server/src/database/migrations/20251223T130000-page-permissions.ts create mode 100644 apps/server/src/database/repos/page/page-permission.repo.ts diff --git a/apps/server/src/common/helpers/types/permission.ts b/apps/server/src/common/helpers/types/permission.ts index 1f4f8664..5493bdb4 100644 --- a/apps/server/src/common/helpers/types/permission.ts +++ b/apps/server/src/common/helpers/types/permission.ts @@ -14,3 +14,12 @@ export enum SpaceVisibility { OPEN = 'open', // any workspace member can see that it exists and join. PRIVATE = 'private', // only added space users can see } + +export enum PageAccessLevel { + RESTRICTED = 'restricted', // only specific users/groups can view or edit +} + +export enum PagePermissionRole { + READER = 'reader', // can only view content and descendants + WRITER = 'writer', // can edit content, descendants, and add new users to permission +} diff --git a/apps/server/src/core/page/dto/page-permission.dto.ts b/apps/server/src/core/page/dto/page-permission.dto.ts new file mode 100644 index 00000000..17bd75a3 --- /dev/null +++ b/apps/server/src/core/page/dto/page-permission.dto.ts @@ -0,0 +1,67 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { PagePermissionRole } from '../../../common/helpers/types/permission'; + +export class PageIdDto { + @IsString() + @IsNotEmpty() + pageId: string; +} + +export class RestrictPageDto extends PageIdDto {} + +export class AddPagePermissionDto extends PageIdDto { + @IsEnum(PagePermissionRole) + role: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(25, { + message: 'userIds must be an array with no more than 25 elements', + }) + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + userIds?: string[]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(25, { + message: 'groupIds must be an array with no more than 25 elements', + }) + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + groupIds?: string[]; +} + +export class RemovePagePermissionDto extends PageIdDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + groupId?: string; +} + +export class UpdatePagePermissionRoleDto extends PageIdDto { + @IsEnum(PagePermissionRole) + role: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + groupId?: string; +} + +export class RemovePageRestrictionDto extends PageIdDto {} diff --git a/apps/server/src/core/page/page-permission.controller.ts b/apps/server/src/core/page/page-permission.controller.ts new file mode 100644 index 00000000..987f6ad9 --- /dev/null +++ b/apps/server/src/core/page/page-permission.controller.ts @@ -0,0 +1,107 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { PagePermissionService } from './services/page-permission.service'; +import { + AddPagePermissionDto, + PageIdDto, + RemovePagePermissionDto, + RemovePageRestrictionDto, + RestrictPageDto, + UpdatePagePermissionRoleDto, +} from './dto/page-permission.dto'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { User, Workspace } from '@docmost/db/types/entity.types'; + +@UseGuards(JwtAuthGuard) +@Controller('pages/permissions') +export class PagePermissionController { + constructor( + private readonly pagePermissionService: PagePermissionService, + ) {} + + @HttpCode(HttpStatus.OK) + @Post('restrict') + async restrictPage( + @Body() dto: RestrictPageDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + await this.pagePermissionService.restrictPage(dto.pageId, user, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('add') + async addPagePermission( + @Body() dto: AddPagePermissionDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + if ( + (!dto.userIds || dto.userIds.length === 0) && + (!dto.groupIds || dto.groupIds.length === 0) + ) { + throw new BadRequestException('userIds or groupIds is required'); + } + + await this.pagePermissionService.addPagePermissions(dto, user, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('remove') + async removePagePermission( + @Body() dto: RemovePagePermissionDto, + @AuthUser() user: User, + ) { + if (!dto.userId && !dto.groupId) { + throw new BadRequestException('userId or groupId is required'); + } + + await this.pagePermissionService.removePagePermission(dto, user); + } + + @HttpCode(HttpStatus.OK) + @Post('update-role') + async updatePagePermissionRole( + @Body() dto: UpdatePagePermissionRoleDto, + @AuthUser() user: User, + ) { + if (!dto.userId && !dto.groupId) { + throw new BadRequestException('userId or groupId is required'); + } + + await this.pagePermissionService.updatePagePermissionRole(dto, user); + } + + @HttpCode(HttpStatus.OK) + @Post('unrestrict') + async removePageRestriction( + @Body() dto: RemovePageRestrictionDto, + @AuthUser() user: User, + ) { + await this.pagePermissionService.removePageRestriction(dto.pageId, user); + } + + @HttpCode(HttpStatus.OK) + @Post('list') + async getPagePermissions( + @Body() dto: PageIdDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + ) { + return this.pagePermissionService.getPagePermissions( + dto.pageId, + user, + pagination, + ); + } +} diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 9dfba84a..2ea9f905 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -3,12 +3,14 @@ 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-permission.service'; +import { PagePermissionController } from './page-permission.controller'; import { StorageModule } from '../../integrations/storage/storage.module'; @Module({ - controllers: [PageController], - providers: [PageService, PageHistoryService, TrashCleanupService], - exports: [PageService, PageHistoryService], + controllers: [PageController, PagePermissionController], + providers: [PageService, PageHistoryService, TrashCleanupService, PagePermissionService], + exports: [PageService, PageHistoryService, PagePermissionService], imports: [StorageModule], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page-permission.service.ts b/apps/server/src/core/page/services/page-permission.service.ts new file mode 100644 index 00000000..373526e6 --- /dev/null +++ b/apps/server/src/core/page/services/page-permission.service.ts @@ -0,0 +1,478 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { + AddPagePermissionDto, + RemovePagePermissionDto, + UpdatePagePermissionRoleDto, +} from '../dto/page-permission.dto'; +import { Page, User } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { + PageAccessLevel, + PagePermissionRole, +} from '../../../common/helpers/types/permission'; +import { executeTx } from '@docmost/db/utils'; +import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../../casl/interfaces/space-ability.type'; + +@Injectable() +export class PagePermissionService { + constructor( + private pagePermissionRepo: PagePermissionRepo, + private pageRepo: PageRepo, + private spaceAbility: SpaceAbilityFactory, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async restrictPage( + pageId: string, + authUser: User, + workspaceId: string, + ): Promise { + const page = await this.pageRepo.findById(pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(authUser, page.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + // TODO: does this check if any of the page's ancestor's is restricted and the user don't have access to it? + // to have access to this page, they must already have access to the page if any of it's ancestor's is restricted + + const existingAccess = + await this.pagePermissionRepo.findPageAccessByPageId(pageId); + if (existingAccess) { + throw new BadRequestException('Page is already restricted'); + } + + await executeTx(this.db, async (trx) => { + const pageAccess = await this.pagePermissionRepo.insertPageAccess( + { + pageId: pageId, + workspaceId: workspaceId, + accessLevel: PageAccessLevel.RESTRICTED, + creatorId: authUser.id, + }, + trx, + ); + + await this.pagePermissionRepo.insertPagePermissions( + [ + { + pageAccessId: pageAccess.id, + userId: authUser.id, + role: PagePermissionRole.WRITER, + addedById: authUser.id, + }, + ], + trx, + ); + }); + } + + async addPagePermissions( + dto: AddPagePermissionDto, + authUser: User, + workspaceId: string, + ): Promise { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + await this.validateWriteAccess(page, authUser); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + dto.pageId, + ); + if (!pageAccess) { + throw new BadRequestException( + 'Page is not restricted. Restrict the page first.', + ); + } + + let validUsers = []; + let validGroups = []; + + if (dto.userIds && dto.userIds.length > 0) { + validUsers = await this.db + .selectFrom('users') + .select(['id']) + .where('id', 'in', dto.userIds) + .where('workspaceId', '=', workspaceId) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pagePermissions') + .select('id') + .whereRef('pagePermissions.userId', '=', 'users.id') + .where('pagePermissions.pageAccessId', '=', pageAccess.id), + ), + ), + ) + .execute(); + } + + if (dto.groupIds && dto.groupIds.length > 0) { + validGroups = await this.db + .selectFrom('groups') + .select(['id']) + .where('id', 'in', dto.groupIds) + .where('workspaceId', '=', workspaceId) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pagePermissions') + .select('id') + .whereRef('pagePermissions.groupId', '=', 'groups.id') + .where('pagePermissions.pageAccessId', '=', pageAccess.id), + ), + ), + ) + .execute(); + } + + const permissionsToAdd = []; + + for (const user of validUsers) { + permissionsToAdd.push({ + pageAccessId: pageAccess.id, + userId: user.id, + role: dto.role, + addedById: authUser.id, + }); + } + + for (const group of validGroups) { + permissionsToAdd.push({ + pageAccessId: pageAccess.id, + groupId: group.id, + role: dto.role, + addedById: authUser.id, + }); + } + + if (permissionsToAdd.length > 0) { + await this.pagePermissionRepo.insertPagePermissions(permissionsToAdd); + } + } + + async removePagePermission( + dto: RemovePagePermissionDto, + authUser: User, + ): Promise { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + await this.validateWriteAccess(page, authUser); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + dto.pageId, + ); + if (!pageAccess) { + throw new BadRequestException('Page is not restricted'); + } + + if (!dto.userId && !dto.groupId) { + throw new BadRequestException('Please provide a userId or groupId'); + } + + if (dto.userId) { + const permission = await this.pagePermissionRepo.findPagePermissionByUserId( + pageAccess.id, + dto.userId, + ); + if (!permission) { + throw new NotFoundException('Permission not found'); + } + + if (permission.role === PagePermissionRole.WRITER) { + await this.validateLastWriter(pageAccess.id); + } + + await this.pagePermissionRepo.deletePagePermissionByUserId( + pageAccess.id, + dto.userId, + ); + } else if (dto.groupId) { + const permission = + await this.pagePermissionRepo.findPagePermissionByGroupId( + pageAccess.id, + dto.groupId, + ); + if (!permission) { + throw new NotFoundException('Permission not found'); + } + + if (permission.role === PagePermissionRole.WRITER) { + await this.validateLastWriter(pageAccess.id); + } + + await this.pagePermissionRepo.deletePagePermissionByGroupId( + pageAccess.id, + dto.groupId, + ); + } + } + + async updatePagePermissionRole( + dto: UpdatePagePermissionRoleDto, + authUser: User, + ): Promise { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + await this.validateWriteAccess(page, authUser); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + dto.pageId, + ); + if (!pageAccess) { + throw new BadRequestException('Page is not restricted'); + } + + if (!dto.userId && !dto.groupId) { + throw new BadRequestException('Please provide a userId or groupId'); + } + + if (dto.userId) { + const permission = + await this.pagePermissionRepo.findPagePermissionByUserId( + pageAccess.id, + dto.userId, + ); + if (!permission) { + throw new NotFoundException('Permission not found'); + } + + if (permission.role === dto.role) { + return; + } + + if (permission.role === PagePermissionRole.WRITER) { + await this.validateLastWriter(pageAccess.id); + } + + await this.pagePermissionRepo.updatePagePermissionRole( + pageAccess.id, + dto.role, + { userId: dto.userId }, + ); + } else if (dto.groupId) { + const permission = + await this.pagePermissionRepo.findPagePermissionByGroupId( + pageAccess.id, + dto.groupId, + ); + if (!permission) { + throw new NotFoundException('Permission not found'); + } + + if (permission.role === dto.role) { + return; + } + + if (permission.role === PagePermissionRole.WRITER) { + await this.validateLastWriter(pageAccess.id); + } + + await this.pagePermissionRepo.updatePagePermissionRole( + pageAccess.id, + dto.role, + { groupId: dto.groupId }, + ); + } + } + + async removePageRestriction(pageId: string, authUser: User): Promise { + const page = await this.pageRepo.findById(pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + await this.validateWriteAccess(page, authUser); + + const pageAccess = + await this.pagePermissionRepo.findPageAccessByPageId(pageId); + if (!pageAccess) { + throw new BadRequestException('Page is not restricted'); + } + + await this.pagePermissionRepo.deletePageAccess(pageId); + } + + async getPagePermissions( + pageId: string, + authUser: User, + pagination: PaginationOptions, + ) { + const page = await this.pageRepo.findById(pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(authUser, page.spaceId); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + const pageAccess = + await this.pagePermissionRepo.findPageAccessByPageId(pageId); + if (!pageAccess) { + return { + items: [], + pagination: { + page: 1, + perPage: pagination.limit, + totalItems: 0, + totalPages: 0, + hasNextPage: false, + hasPrevPage: false, + }, + }; + } + + return this.pagePermissionRepo.getPagePermissionsPaginated( + pageAccess.id, + pagination, + ); + } + + async validateLastWriter(pageAccessId: string): Promise { + const writerCount = + await this.pagePermissionRepo.countWritersByPageAccessId(pageAccessId); + if (writerCount <= 1) { + throw new BadRequestException( + 'There must be at least one user with "Can edit" permission', + ); + } + } + + async hasWritePermission(userId: string, pageId: string): Promise { + const restrictedAncestor = + await this.pagePermissionRepo.findRestrictedAncestor(pageId); + + if (!restrictedAncestor) { + return false; + } + + const permission = await this.pagePermissionRepo.getUserPagePermission( + userId, + restrictedAncestor.pageId, + ); + + return permission?.role === PagePermissionRole.WRITER; + } + + async hasPageAccess(pageId: string): Promise { + const pageAccess = + await this.pagePermissionRepo.findPageAccessByPageId(pageId); + return !!pageAccess; + } + + async validateWriteAccess(page: Page, user: User): Promise { + const hasWritePermission = await this.hasWritePermission(user.id, page.id); + if (hasWritePermission) { + return; + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + } + + /** + * Check if user can view a page. + * Returns true if: + * - Page has no restricted ancestor: fall back to space permission + * - Page has restricted ancestor: user has reader or writer permission on that ancestor + */ + async canViewPage(userId: string, pageId: string): Promise { + const restrictedAncestor = + await this.pagePermissionRepo.findRestrictedAncestor(pageId); + + if (!restrictedAncestor) { + return true; // no page-level restriction, defer to space permission + } + + const permission = await this.pagePermissionRepo.getUserPagePermission( + userId, + restrictedAncestor.pageId, + ); + + return !!permission; // has any permission (reader or writer) + } + + /** + * Check if user can edit a page. + * Returns true if: + * - Page has no restricted ancestor: fall back to space permission + * - Page has restricted ancestor: user has writer permission on that ancestor + */ + async canEditPage(userId: string, pageId: string): Promise { + const restrictedAncestor = + await this.pagePermissionRepo.findRestrictedAncestor(pageId); + + if (!restrictedAncestor) { + return true; // no page-level restriction, defer to space permission + } + + const permission = await this.pagePermissionRepo.getUserPagePermission( + userId, + restrictedAncestor.pageId, + ); + + return permission?.role === PagePermissionRole.WRITER; + } + + /** + * Validate user can view page, throws ForbiddenException if not. + * Checks both space-level and page-level permissions. + */ + async validateCanView(page: Page, user: User): Promise { + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + const canView = await this.canViewPage(user.id, page.id); + if (!canView) { + throw new ForbiddenException(); + } + } + + /** + * Validate user can edit page, throws ForbiddenException if not. + * Checks both space-level and page-level permissions. + */ + async validateCanEdit(page: Page, user: User): Promise { + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + const canEdit = await this.canEditPage(user.id, page.id); + if (!canEdit) { + throw new ForbiddenException(); + } + } +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index bd331ada..ea346dd1 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -16,6 +16,7 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { PageRepo } from './repos/page/page.repo'; +import { PagePermissionRepo } from './repos/page/page-permission.repo'; import { CommentRepo } from './repos/comment/comment.repo'; import { PageHistoryRepo } from './repos/page/page-history.repo'; import { AttachmentRepo } from './repos/attachment/attachment.repo'; @@ -71,6 +72,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); SpaceRepo, SpaceMemberRepo, PageRepo, + PagePermissionRepo, PageHistoryRepo, CommentRepo, AttachmentRepo, @@ -87,6 +89,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); SpaceRepo, SpaceMemberRepo, PageRepo, + PagePermissionRepo, PageHistoryRepo, CommentRepo, AttachmentRepo, diff --git a/apps/server/src/database/migrations/20251223T130000-page-permissions.ts b/apps/server/src/database/migrations/20251223T130000-page-permissions.ts new file mode 100644 index 00000000..ba619365 --- /dev/null +++ b/apps/server/src/database/migrations/20251223T130000-page-permissions.ts @@ -0,0 +1,93 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_access') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('page_id', 'uuid', (col) => + col.notNull().unique().references('pages.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('access_level', 'varchar', (col) => col.notNull()) + .addColumn('creator_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()`), + ) + .execute(); + + await db.schema + .createTable('page_permissions') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('page_access_id', 'uuid', (col) => + col.notNull().references('page_access.id').onDelete('cascade'), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade'), + ) + .addColumn('group_id', 'uuid', (col) => + col.references('groups.id').onDelete('cascade'), + ) + .addColumn('role', 'varchar', (col) => col.notNull()) + .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()`), + ) + .addUniqueConstraint('page_access_user_unique', [ + 'page_access_id', + 'user_id', + ]) + .addUniqueConstraint('page_access_group_unique', [ + 'page_access_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 + .createIndex('idx_page_access_workspace') + .on('page_access') + .column('workspace_id') + .execute(); + + await db.schema + .createIndex('idx_page_permissions_page_access') + .on('page_permissions') + .column('page_access_id') + .execute(); + + await db.schema + .createIndex('idx_page_permissions_user') + .on('page_permissions') + .column('user_id') + .execute(); + + await db.schema + .createIndex('idx_page_permissions_group') + .on('page_permissions') + .column('group_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_permissions').ifExists().execute(); + await db.schema.dropTable('page_access').ifExists().execute(); +} diff --git a/apps/server/src/database/repos/page/page-permission.repo.ts b/apps/server/src/database/repos/page/page-permission.repo.ts new file mode 100644 index 00000000..cf3c4f09 --- /dev/null +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -0,0 +1,270 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { + InsertablePageAccess, + InsertablePagePermission, + PageAccess, + PagePermission, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { sql } from 'kysely'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; + +@Injectable() +export class PagePermissionRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly groupRepo: GroupRepo, + ) {} + + async findPageAccessByPageId( + pageId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('pageAccess') + .selectAll() + .where('pageId', '=', pageId) + .executeTakeFirst(); + } + + async insertPageAccess( + data: InsertablePageAccess, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('pageAccess') + .values(data) + .returningAll() + .executeTakeFirst(); + } + + async deletePageAccess(pageId: string, trx?: KyselyTransaction): Promise { + const db = dbOrTx(this.db, trx); + await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute(); + } + + async insertPagePermissions( + permissions: InsertablePagePermission[], + trx?: KyselyTransaction, + ): Promise { + if (permissions.length === 0) return; + const db = dbOrTx(this.db, trx); + await db + .insertInto('pagePermissions') + .values(permissions) + .execute(); + } + + async findPagePermissionByUserId( + pageAccessId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('pagePermissions') + .selectAll() + .where('pageAccessId', '=', pageAccessId) + .where('userId', '=', userId) + .executeTakeFirst(); + } + + async findPagePermissionByGroupId( + pageAccessId: string, + groupId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('pagePermissions') + .selectAll() + .where('pageAccessId', '=', pageAccessId) + .where('groupId', '=', groupId) + .executeTakeFirst(); + } + + async deletePagePermissionByUserId( + pageAccessId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('pagePermissions') + .where('pageAccessId', '=', pageAccessId) + .where('userId', '=', userId) + .execute(); + } + + async deletePagePermissionByGroupId( + pageAccessId: string, + groupId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('pagePermissions') + .where('pageAccessId', '=', pageAccessId) + .where('groupId', '=', groupId) + .execute(); + } + + async updatePagePermissionRole( + pageAccessId: string, + role: string, + opts: { userId?: string; groupId?: string }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + let query = db + .updateTable('pagePermissions') + .set({ role, updatedAt: new Date() }) + .where('pageAccessId', '=', pageAccessId); + + if (opts.userId) { + query = query.where('userId', '=', opts.userId); + } else if (opts.groupId) { + query = query.where('groupId', '=', opts.groupId); + } + + await query.execute(); + } + + async countWritersByPageAccessId( + pageAccessId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + const result = await db + .selectFrom('pagePermissions') + .select((eb) => eb.fn.count('id').as('count')) + .where('pageAccessId', '=', pageAccessId) + .where('role', '=', 'writer') + .executeTakeFirst(); + return Number(result?.count ?? 0); + } + + async getPagePermissionsPaginated( + pageAccessId: string, + pagination: PaginationOptions, + ) { + let query = this.db + .selectFrom('pagePermissions') + .leftJoin('users', 'users.id', 'pagePermissions.userId') + .leftJoin('groups', 'groups.id', 'pagePermissions.groupId') + .select([ + 'pagePermissions.id', + 'pagePermissions.role', + 'pagePermissions.createdAt', + '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', + ]) + .select((eb) => this.groupRepo.withMemberCount(eb)) + .where('pageAccessId', '=', pageAccessId) + .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, + }); + + const members = result.items.map((member) => { + if (member.userId) { + return { + id: member.userId, + name: member.userName, + email: member.userEmail, + avatarUrl: member.userAvatarUrl, + type: 'user' as const, + role: member.role, + createdAt: member.createdAt, + }; + } else { + return { + id: member.groupId, + name: member.groupName, + memberCount: member.memberCount as number, + isDefault: member.groupIsDefault, + type: 'group' as const, + role: member.role, + createdAt: member.createdAt, + }; + } + }); + + result.items = members as any; + return result; + } + + async getUserPagePermission( + userId: string, + pageId: string, + ): Promise<{ role: string } | undefined> { + const result = await this.db + .selectFrom('pageAccess') + .innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id') + .select(['pagePermissions.role']) + .where('pageAccess.pageId', '=', pageId) + .where('pagePermissions.userId', '=', userId) + .unionAll( + this.db + .selectFrom('pageAccess') + .innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id') + .innerJoin('groupUsers', 'groupUsers.groupId', 'pagePermissions.groupId') + .select(['pagePermissions.role']) + .where('pageAccess.pageId', '=', pageId) + .where('groupUsers.userId', '=', userId), + ) + .executeTakeFirst(); + + return result; + } + + async findRestrictedAncestor( + pageId: string, + ): Promise<{ pageId: string; accessLevel: string; depth: number } | undefined> { + return this.db + .selectFrom('pageHierarchy') + .innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId') + .select([ + 'pageAccess.pageId', + 'pageAccess.accessLevel', + 'pageHierarchy.depth', + ]) + .where('pageHierarchy.descendantId', '=', pageId) + .orderBy('pageHierarchy.depth', 'asc') + .executeTakeFirst(); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index bf65d5cc..1b70e765 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -366,6 +366,27 @@ export interface Workspaces { updatedAt: Generated; } +export interface PageAccess { + id: Generated; + pageId: string; + workspaceId: string; + accessLevel: string; + creatorId: string | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface PagePermissions { + id: Generated; + pageAccessId: string; + userId: string | null; + groupId: string | null; + role: string; + addedById: string | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface DB { apiKeys: ApiKeys; attachments: Attachments; @@ -377,8 +398,10 @@ export interface DB { fileTasks: FileTasks; groups: Groups; groupUsers: GroupUsers; + pageAccess: PageAccess; pageHierarchy: PageHierarchy; pageHistory: PageHistory; + pagePermissions: PagePermissions; pages: Pages; shares: Shares; spaceMembers: SpaceMembers; diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index 2ad7f4b8..bd6af94f 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -9,8 +9,10 @@ import { FileTasks, Groups, GroupUsers, + PageAccess, PageHierarchy, PageHistory, + PagePermissions, Pages, Shares, SpaceMembers, @@ -33,9 +35,11 @@ export interface DbInterface { fileTasks: FileTasks; groups: Groups; groupUsers: GroupUsers; + pageAccess: PageAccess; pageHierarchy: PageHierarchy; pageEmbeddings: PageEmbeddings; pageHistory: PageHistory; + pagePermissions: PagePermissions; pages: Pages; shares: Shares; spaceMembers: SpaceMembers; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 0985eda9..a2edee91 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -3,7 +3,9 @@ import { Attachments, Comments, Groups, + PageAccess as _PageAccess, PageHierarchy as _PageHierarchy, + PagePermissions as _PagePermissions, Pages, Spaces, Users, @@ -136,3 +138,13 @@ export type UpdatablePageEmbedding = Updateable>; // Page Hierarchy (closure table - composite primary key) export type PageHierarchy = Selectable<_PageHierarchy>; export type InsertablePageHierarchy = Insertable<_PageHierarchy>; + +// Page Access +export type PageAccess = Selectable<_PageAccess>; +export type InsertablePageAccess = Insertable<_PageAccess>; +export type UpdatablePageAccess = Updateable>; + +// Page Permission +export type PagePermission = Selectable<_PagePermissions>; +export type InsertablePagePermission = Insertable<_PagePermissions>; +export type UpdatablePagePermission = Updateable>;