diff --git a/apps/server/src/core/page/page-permission/dto/page-permission.dto.ts b/apps/server/src/core/page/page-permission/dto/page-permission.dto.ts new file mode 100644 index 00000000..5a6fd4c5 --- /dev/null +++ b/apps/server/src/core/page/page-permission/dto/page-permission.dto.ts @@ -0,0 +1,67 @@ +import { + ArrayNotEmpty, + IsArray, + IsIn, + IsOptional, + IsString, + IsUUID, + ValidateIf, +} from 'class-validator'; + +export const PAGE_PERMISSION_ROLES = ['reader', 'writer'] as const; +export type PagePermissionRoleValue = (typeof PAGE_PERMISSION_ROLES)[number]; + +export class PageIdBodyDto { + @IsString() + pageId: string; +} + +export class AddPagePermissionDto { + @IsString() + pageId: string; + + @IsIn(PAGE_PERMISSION_ROLES as unknown as string[]) + role: PagePermissionRoleValue; + + @ValidateIf((o) => !o.groupIds || o.groupIds.length === 0) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + userIds?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + groupIds?: string[]; +} + +export class RemovePagePermissionDto { + @IsString() + pageId: string; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + userIds?: string[]; + + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + groupIds?: string[]; +} + +export class UpdatePagePermissionDto { + @IsString() + pageId: string; + + @IsIn(PAGE_PERMISSION_ROLES as unknown as string[]) + role: PagePermissionRoleValue; + + @ValidateIf((o) => !o.groupId) + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + groupId?: string; +} diff --git a/apps/server/src/core/page/page-permission/page-permission.controller.ts b/apps/server/src/core/page/page-permission/page-permission.controller.ts new file mode 100644 index 00000000..86862096 --- /dev/null +++ b/apps/server/src/core/page/page-permission/page-permission.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { User } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { PagePermissionService } from './page-permission.service'; +import { + AddPagePermissionDto, + PageIdBodyDto, + RemovePagePermissionDto, + UpdatePagePermissionDto, +} from './dto/page-permission.dto'; + +/** + * Page-sharing endpoints — Outline-style granular access. + * Mounted under the same /pages prefix as PageController, so the existing + * client (`apps/client/src/ee/page-permission/services/page-permission-service.ts`) + * works without any URL changes. + */ +@UseGuards(JwtAuthGuard) +@Controller('pages') +export class PagePermissionController { + constructor(private readonly pagePermissionService: PagePermissionService) {} + + @HttpCode(HttpStatus.NO_CONTENT) + @Post('restrict') + async restrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) { + await this.pagePermissionService.restrictPage(dto.pageId, user); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Post('remove-restriction') + async unrestrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) { + await this.pagePermissionService.unrestrictPage(dto.pageId, user); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Post('add-permission') + async addPermission( + @Body() dto: AddPagePermissionDto, + @AuthUser() user: User, + ) { + await this.pagePermissionService.addPermissions(dto, user); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Post('remove-permission') + async removePermission( + @Body() dto: RemovePagePermissionDto, + @AuthUser() user: User, + ) { + await this.pagePermissionService.removePermissions(dto, user); + } + + @HttpCode(HttpStatus.NO_CONTENT) + @Post('update-permission') + async updatePermission( + @Body() dto: UpdatePagePermissionDto, + @AuthUser() user: User, + ) { + await this.pagePermissionService.updatePermissionRole(dto, user); + } + + @HttpCode(HttpStatus.OK) + @Post('permissions') + async listPermissions( + @Body() body: PageIdBodyDto & { cursor?: string; query?: string }, + @AuthUser() user: User, + ) { + const pagination: PaginationOptions = { + limit: 50, + cursor: body.cursor, + query: body.query, + } as PaginationOptions; + return this.pagePermissionService.listPermissions( + body.pageId, + user, + pagination as any, + ); + } + + @HttpCode(HttpStatus.OK) + @Post('permission-info') + async getRestrictionInfo( + @Body() dto: PageIdBodyDto, + @AuthUser() user: User, + ) { + return this.pagePermissionService.getRestrictionInfo(dto.pageId, user); + } +} diff --git a/apps/server/src/core/page/page-permission/page-permission.module.ts b/apps/server/src/core/page/page-permission/page-permission.module.ts new file mode 100644 index 00000000..b7746cc2 --- /dev/null +++ b/apps/server/src/core/page/page-permission/page-permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PagePermissionController } from './page-permission.controller'; +import { PagePermissionService } from './page-permission.service'; + +@Module({ + controllers: [PagePermissionController], + providers: [PagePermissionService], +}) +export class PagePermissionModule {} diff --git a/apps/server/src/core/page/page-permission/page-permission.service.ts b/apps/server/src/core/page/page-permission/page-permission.service.ts new file mode 100644 index 00000000..5f001af4 --- /dev/null +++ b/apps/server/src/core/page/page-permission/page-permission.service.ts @@ -0,0 +1,316 @@ +import { + 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 { Page, User } from '@docmost/db/types/entity.types'; +import { PageAccessService } from '../page-access/page-access.service'; +import { + PageAccessLevel, + PagePermissionRole, +} from '../../../common/helpers/types/permission'; +import { + AddPagePermissionDto, + RemovePagePermissionDto, + UpdatePagePermissionDto, +} from './dto/page-permission.dto'; + +interface RestrictionInfo { + restrictionId?: string; + hasDirectRestriction: boolean; + hasInheritedRestriction: boolean; + inheritedFrom?: { id: string; slugId: string; title: string }; + userAccess: { canView: boolean; canEdit: boolean; canManage: boolean }; +} + +/** + * Page-sharing service — Outline-style granular access on top of the native + * page_access / page_permissions tables. Reuses `PagePermissionRepo` for + * persistence and `PageAccessService` to authorize the caller. + * + * Authorization rule for managing permissions: caller must have edit access + * to the page (page-level if restricted, space-level otherwise). This matches + * Outline's "Editors can share" behavior. + */ +@Injectable() +export class PagePermissionService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly pageAccessService: PageAccessService, + ) {} + + private async loadPageOrThrow(pageId: string): Promise { + const page = await this.pageRepo.findById(pageId); + if (!page) throw new NotFoundException('Page not found'); + return page; + } + + /** + * Restrict the page — promote its access level to RESTRICTED and seed the + * caller as a writer so they don't lock themselves out. Idempotent. + */ + async restrictPage(pageId: string, user: User): Promise { + const page = await this.loadPageOrThrow(pageId); + await this.pageAccessService.validateCanEdit(page, user); + + await this.db.transaction().execute(async (trx) => { + const existing = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + trx, + ); + + let pageAccess = existing; + if (!pageAccess) { + pageAccess = await this.pagePermissionRepo.insertPageAccess( + { + pageId: page.id, + workspaceId: page.workspaceId, + spaceId: page.spaceId, + accessLevel: PageAccessLevel.RESTRICTED, + creatorId: user.id, + }, + trx, + ); + } + + // Seed the caller as a writer so they retain access after restriction. + const existingPerm = await this.pagePermissionRepo.findPagePermissionByUserId( + pageAccess.id, + user.id, + trx, + ); + if (!existingPerm) { + await this.pagePermissionRepo.insertPagePermissions( + [ + { + pageAccessId: pageAccess.id, + userId: user.id, + role: PagePermissionRole.WRITER, + addedById: user.id, + }, + ], + trx, + ); + } + }); + } + + async unrestrictPage(pageId: string, user: User): Promise { + const page = await this.loadPageOrThrow(pageId); + await this.pageAccessService.validateCanEdit(page, user); + // ON DELETE CASCADE on page_permissions handles cleanup. + await this.pagePermissionRepo.deletePageAccess(page.id); + } + + async addPermissions(dto: AddPagePermissionDto, user: User): Promise { + const page = await this.loadPageOrThrow(dto.pageId); + await this.pageAccessService.validateCanEdit(page, user); + + const userIds = dto.userIds ?? []; + const groupIds = dto.groupIds ?? []; + if (userIds.length === 0 && groupIds.length === 0) { + return; + } + + await this.db.transaction().execute(async (trx) => { + let pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + trx, + ); + // Auto-promote to restricted on first add — matches the Notion-like + // affordance "share with X = make it restricted". + if (!pageAccess) { + pageAccess = await this.pagePermissionRepo.insertPageAccess( + { + pageId: page.id, + workspaceId: page.workspaceId, + spaceId: page.spaceId, + accessLevel: PageAccessLevel.RESTRICTED, + creatorId: user.id, + }, + trx, + ); + } + + const newRows = [ + ...userIds.map((uid) => ({ + pageAccessId: pageAccess!.id, + userId: uid, + role: dto.role, + addedById: user.id, + })), + ...groupIds.map((gid) => ({ + pageAccessId: pageAccess!.id, + groupId: gid, + role: dto.role, + addedById: user.id, + })), + ]; + + // Drop any existing rows for the same (pageAccess, user/group) so the + // insert is idempotent and the role is updated on re-add. + if (userIds.length > 0) { + await this.pagePermissionRepo.deletePagePermissionsByUserIds( + pageAccess!.id, + userIds, + trx, + ); + } + if (groupIds.length > 0) { + await this.pagePermissionRepo.deletePagePermissionsByGroupIds( + pageAccess!.id, + groupIds, + trx, + ); + } + await this.pagePermissionRepo.insertPagePermissions(newRows, trx); + }); + } + + async removePermissions( + dto: RemovePagePermissionDto, + user: User, + ): Promise { + const page = await this.loadPageOrThrow(dto.pageId); + await this.pageAccessService.validateCanEdit(page, user); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + ); + if (!pageAccess) return; + + if (dto.userIds && dto.userIds.length > 0) { + await this.pagePermissionRepo.deletePagePermissionsByUserIds( + pageAccess.id, + dto.userIds, + ); + } + if (dto.groupIds && dto.groupIds.length > 0) { + await this.pagePermissionRepo.deletePagePermissionsByGroupIds( + pageAccess.id, + dto.groupIds, + ); + } + } + + async updatePermissionRole( + dto: UpdatePagePermissionDto, + user: User, + ): Promise { + const page = await this.loadPageOrThrow(dto.pageId); + await this.pageAccessService.validateCanEdit(page, user); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + ); + if (!pageAccess) { + throw new NotFoundException('Page is not restricted'); + } + + if (!dto.userId && !dto.groupId) { + throw new ForbiddenException('userId or groupId required'); + } + + // Guard against demoting the last writer to reader (locks out everyone). + if (dto.role === PagePermissionRole.READER) { + const writers = await this.pagePermissionRepo.countWritersByPageAccessId( + pageAccess.id, + ); + const target = dto.userId + ? await this.pagePermissionRepo.findPagePermissionByUserId( + pageAccess.id, + dto.userId, + ) + : await this.pagePermissionRepo.findPagePermissionByGroupId( + pageAccess.id, + dto.groupId!, + ); + if ( + writers <= 1 && + target?.role === PagePermissionRole.WRITER + ) { + throw new ForbiddenException( + 'Cannot demote the last writer of a restricted page', + ); + } + } + + await this.pagePermissionRepo.updatePagePermissionRole( + pageAccess.id, + dto.role, + { userId: dto.userId, groupId: dto.groupId }, + ); + } + + async listPermissions( + pageId: string, + user: User, + pagination: { limit: number; cursor?: string; query?: string }, + ) { + const page = await this.loadPageOrThrow(pageId); + // Only people who can view the page can see who has access. + await this.pageAccessService.validateCanView(page, user); + + const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + ); + if (!pageAccess) { + return { items: [], meta: { hasNextPage: false, hasPrevPage: false } }; + } + return this.pagePermissionRepo.getPagePermissionsPaginated( + pageAccess.id, + pagination as any, + ); + } + + async getRestrictionInfo( + pageId: string, + user: User, + ): Promise { + const page = await this.loadPageOrThrow(pageId); + await this.pageAccessService.validateCanView(page, user); + + const direct = await this.pagePermissionRepo.findPageAccessByPageId( + page.id, + ); + const ancestor = await this.pagePermissionRepo.findRestrictedAncestor( + page.id, + ); + // findRestrictedAncestor returns the SAME page when the page itself is + // restricted (depth=0). We only consider it "inherited" when depth > 0. + const isInherited = + !!ancestor && ancestor.depth > 0 && ancestor.pageId !== page.id; + const inheritedPage = isInherited + ? await this.pageRepo.findById(ancestor!.pageId) + : undefined; + + const { hasAnyRestriction, canAccess, canEdit } = + await this.pagePermissionRepo.canUserEditPage(user.id, page.id); + + return { + restrictionId: direct?.id, + hasDirectRestriction: !!direct, + hasInheritedRestriction: isInherited, + inheritedFrom: inheritedPage + ? { + id: inheritedPage.id, + slugId: inheritedPage.slugId, + title: inheritedPage.title ?? '', + } + : undefined, + userAccess: { + canView: hasAnyRestriction ? canAccess : true, + canEdit: hasAnyRestriction ? canEdit : true, + // For now: anyone who can edit can manage permissions. Mirrors the + // service-level rule used by addPermissions/restrict. + canManage: hasAnyRestriction ? canEdit : true, + }, + }; + } +} diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index a2042279..f2e54739 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -6,10 +6,17 @@ import { TrashCleanupService } from './services/trash-cleanup.service'; import { StorageModule } from '../../integrations/storage/storage.module'; import { CollaborationModule } from '../../collaboration/collaboration.module'; import { WatcherModule } from '../watcher/watcher.module'; +import { PagePermissionController } from './page-permission/page-permission.controller'; +import { PagePermissionService } from './page-permission/page-permission.service'; @Module({ - controllers: [PageController], - providers: [PageService, PageHistoryService, TrashCleanupService], + controllers: [PageController, PagePermissionController], + providers: [ + PageService, + PageHistoryService, + TrashCleanupService, + PagePermissionService, + ], exports: [PageService, PageHistoryService], imports: [StorageModule, CollaborationModule, WatcherModule], }) diff --git a/apps/server/src/integrations/environment/license-check.service.ts b/apps/server/src/integrations/environment/license-check.service.ts index 35c2295a..82071fb7 100644 --- a/apps/server/src/integrations/environment/license-check.service.ts +++ b/apps/server/src/integrations/environment/license-check.service.ts @@ -1,6 +1,21 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { EnvironmentService } from './environment.service'; +import { Feature } from '../../common/features'; + +/** + * Acadenice — features OSS-ifiees inconditionnellement (open-source). + * Toujours injectees dans la reponse `resolveFeatures` quel que soit le plan + * ou la license. Permet de garder le pipeline `useHasFeature(...)` cote front + * et les guards cote serveur sans toucher au flux EE. + * + * Why: reproduit la logique du Patch 020 (branding) et R4.5 (audit, api-keys) + * appliques par overrides UI directs. Centralise ici pour rester DRY. + */ +const ACADENICE_OSS_FEATURES: ReadonlyArray = [ + Feature.PAGE_PERMISSIONS, + Feature.SHARING_CONTROLS, +]; @Injectable() export class LicenseCheckService { @@ -27,6 +42,9 @@ export class LicenseCheckService { } hasFeature(licenseKey: string, feature: string, plan?: string): boolean { + if (ACADENICE_OSS_FEATURES.includes(feature)) { + return true; + } if (this.environmentService.isCloud()) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -63,17 +81,22 @@ export class LicenseCheckService { } resolveFeatures(licenseKey: string, plan: string): string[] { + let base: string[]; if (this.environmentService.isCloud()) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry'); - return [...getFeaturesForCloudPlan(plan)]; + base = [...getFeaturesForCloudPlan(plan)]; } catch { - return []; + base = []; } + } else { + base = this.getFeatures(licenseKey); } - - return this.getFeatures(licenseKey); + // Inject Acadenice OSS-ified features unconditionally. + const set = new Set(base); + for (const f of ACADENICE_OSS_FEATURES) set.add(f); + return [...set]; } resolveTier(licenseKey: string, plan: string): string { diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json index ecb7f40d..42a7a2f1 100644 --- a/apps/server/tsconfig.build.json +++ b/apps/server/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "exclude": ["node_modules", - "test", "dist", "**/*spec.ts"] + "test", "dist", "**/*spec.ts", "vitest.config.ts"] }