Compare commits

..

7 Commits

Author SHA1 Message Date
Philipinho 8a64c43c71 perm share 2026-01-02 01:39:11 +00:00
Philipinho 8eb698648e WIP 5 2025-12-31 10:16:54 +00:00
Philipinho 0c3901abf5 WIP 4 2025-12-29 22:13:58 +00:00
Philipinho c2e722ee5c WIP 3 2025-12-24 00:27:25 +00:00
Philipinho f65726ae26 Fix permission - WIP 2025-12-23 23:05:04 +00:00
Philipinho 68a838606a WIP 2025-12-23 22:41:29 +00:00
Philipinho b0ceae39ba Add page_hierarchy table 2025-12-23 16:05:48 +00:00
38 changed files with 2273 additions and 838 deletions
@@ -20,7 +20,6 @@ import {
IconCalendar,
IconAppWindow,
IconSitemap,
IconLayoutColumns,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -244,51 +243,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
{
title: "Columns",
description: "Insert 2 columns layout.",
searchTerms: ["columns", "layout", "grid", "side by side"],
icon: IconLayoutColumns,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "column_container",
content: [
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
{
type: "column",
attrs: { colWidth: 200 },
content: [
{
type: "paragraph",
},
],
},
],
})
.run();
},
},
{
title: "Toggle block",
description: "Insert collapsible block.",
@@ -46,7 +46,6 @@ import {
Heading,
Highlight,
UniqueID,
ColumnsExtension,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -230,7 +229,6 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
ColumnsExtension,
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -1,123 +0,0 @@
.resize-cursor {
cursor: col-resize;
}
.prosemirror-column-container {
display: flex;
flex-direction: row;
width: calc(100% - 8px);
gap: 12px;
margin: 16px 0;
}
.prosemirror-column-container.has-focus .prosemirror-column,
.prosemirror-column-container:hover .prosemirror-column {
background-color: rgba(100, 106, 115, 0.05);
}
.prosemirror-column-container .prosemirror-column {
position: relative;
border-radius: 8px;
min-width: 50px;
padding: 12px;
background-color: transparent;
transition: background-color 0.2s ease;
}
.prosemirror-column-container
.prosemirror-column
> :not(div.grid-resize-handle):nth-child(1),
.prosemirror-column-container
.prosemirror-column
> div.grid-resize-handle
+ :nth-child(2) {
margin-top: 0;
}
.prosemirror-column-container .prosemirror-column > :nth-last-child(1) {
margin-bottom: 0;
}
.prosemirror-column-container .prosemirror-column .grid-resize-handle {
position: absolute;
right: -7px;
top: 0;
bottom: 0;
width: 2px;
z-index: 20;
background-color: #336df4;
pointer-events: none;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button {
top: -8px;
left: -9px;
width: 12px;
height: 12px;
background-color: #007bff;
border: 4px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
pointer-events: auto;
cursor: pointer;
transition: transform 0.1s ease-in-out;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button:hover {
transform: scale(1.35);
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus {
position: relative;
width: 8px;
height: 8px;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::before,
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::after {
content: '';
position: absolute;
background-color: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::before {
width: 8px;
height: 2px;
}
.prosemirror-column-container
.prosemirror-column
.grid-resize-handle
.circle-button
.plus::after {
width: 24px;
height: 8px;
}
@@ -13,4 +13,3 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./column.css";
@@ -35,7 +35,6 @@ import {
Subpages,
Highlight,
UniqueID,
ColumnsExtension,
addUniqueIdsToDoc,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
@@ -89,7 +88,6 @@ export const tiptapExtensions = [
Embed,
Mention,
Subpages,
ColumnsExtension
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -68,9 +70,31 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
// Check page-level permissions
const { hasRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permissions
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connection.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -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
}
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page-access/page-access.service';
@Controller()
export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +113,8 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Checks both space-level and page-level edit permissions
await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -171,15 +168,14 @@ export class AttachmentController {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
// Checks both space-level and page-level view permissions
await this.pageAccessService.validateCanView(page, user);
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page-access/page-access.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +34,7 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,10 +49,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.create(
{
@@ -75,10 +74,8 @@ export class CommentController {
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();
}
await this.pageAccessService.validateCanView(page, user);
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,13 +87,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return comment;
}
@@ -108,18 +105,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user);
}
@@ -131,41 +123,27 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
);
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
+2
View File
@@ -14,6 +14,7 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
@@ -29,6 +30,7 @@ import { ShareModule } from './share/share.module';
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
],
})
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -0,0 +1,71 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canAccess } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canAccess) {
throw new ForbiddenException();
}
}
// No restriction - space membership (checked above) is sufficient for view
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(page: Page, user: User): Promise<void> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
}
}
@@ -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 {}
@@ -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,
);
}
}
+71 -35
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageAccessService } from '../page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -44,6 +45,7 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -61,10 +63,7 @@ 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)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return page;
}
@@ -76,12 +75,24 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return this.pageService.create(user.id, workspace.id, createPageDto);
@@ -96,10 +107,7 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
return this.pageService.update(page, updatePageDto, user.id);
}
@@ -128,10 +136,9 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -153,11 +160,18 @@ export class PageController {
throw new NotFoundException('Page not found');
}
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
// so page is virtually lost. Fix.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
// Check page-level edit permission (if restoring to a restricted ancestor)
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -184,6 +198,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -198,6 +213,7 @@ export class PageController {
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
//TODO: should space admin see deleted pages they dont have access to?
if (deletedPageDto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
@@ -215,7 +231,6 @@ export class PageController {
}
}
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
@@ -228,10 +243,7 @@ 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)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -247,13 +259,14 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
const ability = await this.spaceAbility.createForUser(
user,
history.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
// Get the page to check permissions
const page = await this.pageRepo.findById(history.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return history;
}
@@ -285,7 +298,12 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
);
}
@HttpCode(HttpStatus.OK)
@@ -315,7 +333,11 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
// Check page-level edit permission on the source page
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
}
@HttpCode(HttpStatus.OK)
@@ -326,6 +348,10 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
// Check page-level view permission on the source page (need to read to copy)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -368,10 +394,22 @@ export class PageController {
user,
movedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pageAccessService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (targetParent) {
await this.pageAccessService.validateCanEdit(targetParent, user);
}
}
return this.pageService.movePage(dto, movedPage);
}
@@ -383,10 +421,8 @@ 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)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageService.getPageBreadCrumbs(page.id);
}
}
+5 -3
View File
@@ -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 {}
@@ -0,0 +1,438 @@
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const writerCount =
await this.pagePermissionRepo.countWritersByPageAccessId(pageAccessId);
if (writerCount <= 1) {
throw new BadRequestException(
'There must be at least one user with "Can edit" permission',
);
}
}
/**
* Check if user has writer permission on ALL restricted ancestors of a page.
* Used for permission management operations.
*/
async hasWritePermission(userId: string, pageId: string): Promise<boolean> {
const hasRestriction =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!hasRestriction) {
return false; // no restrictions, defer to space permissions
}
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
async hasPageAccess(pageId: string): Promise<boolean> {
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
return !!pageAccess;
}
async validateWriteAccess(page: Page, user: User): Promise<void> {
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.
* User must have permission (reader or writer) on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has permission on all restricted ancestors
*/
async canViewPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserAccessPage(userId, pageId);
}
/**
* Check if user can edit a page.
* User must have WRITER permission on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has writer permission on all restricted ancestors
*/
async canEditPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
/**
* Filter page IDs to only those the user can access.
*/
async filterAccessiblePages(
pageIds: string[],
userId: string,
): Promise<string[]> {
const results =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
return results.map((r) => r.id);
}
}
@@ -7,6 +7,7 @@ import {
import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
@@ -47,6 +48,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -55,6 +57,61 @@ export class PageService {
private eventEmitter: EventEmitter2,
) {}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<T extends { id: string; parentPageId: string | null }>(
pages: T[],
rootPageId: string,
userId: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
// Build a map for quick lookup
const pageMap = new Map(pages.map((p) => [p.id, p]));
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
async findById(
pageId: string,
includeContent?: boolean,
@@ -167,7 +224,7 @@ export class PageService {
page.id,
);
return await this.pageRepo.findById(page.id, {
return this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
includeCreator: true,
@@ -180,6 +237,7 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
): Promise<any> {
let query = this.db
.selectFrom('pages')
@@ -205,16 +263,83 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
const result = executeWithPagination(query, {
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: 250,
});
if (userId && result.items.length > 0) {
const pageIds = result.items.map((p: any) => p.id);
// Single query to get accessible pages with their edit permissions
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
// Filter and add canEdit flag in one pass
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id),
}));
// For pages with hasChildren: true, verify they have accessible children
const pagesWithChildren = result.items.filter((p: any) => p.hasChildren);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(parentsWithAccessibleChildren);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) => !accessibleIds.has(p.id) && p.parentPageId && accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(rootPage.spaceId, null);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -222,44 +347,50 @@ export class PageService {
rootPage.id,
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
const pageIdsToMove = accessiblePages.map((p) => p.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
pageIdsToMove.filter((id) => id !== rootPage.id),
trx,
);
}
if (pageIds.length > 0) {
if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
pageIdsToMove,
trx,
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
workspaceId: rootPage.workspaceId
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
}
});
@@ -284,10 +415,17 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
);
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -387,9 +525,14 @@ export class PageService {
workspaceId: page.workspaceId,
creatorId: authUser.id,
lastUpdatedById: authUser.id,
parentPageId: page.id === rootPage.id
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
parentPageId:
page.id === rootPage.id
? isDuplicateInSameSpace
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
};
}),
);
@@ -568,16 +711,43 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPages(userId, pagination);
const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getDeletedSpacePages(
+28 -2
View File
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -118,10 +120,22 @@ export class SearchService {
}
//@ts-ignore
queryResults = await queryResults.execute();
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
opts.userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -210,6 +224,18 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
+46 -9
View File
@@ -26,6 +26,8 @@ import {
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -41,6 +43,8 @@ export class ShareController {
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
) {}
@@ -96,6 +100,7 @@ export class ShareController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
// TODO: look into permission
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Shared page not found');
@@ -122,9 +127,21 @@ export class ShareController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
// User must be able to edit the page to create a share
await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (createShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
}
return this.shareService.createShare({
@@ -144,9 +161,26 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (updateShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
}
return this.shareService.updateShare(share.id, updateShareDto);
@@ -161,11 +195,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to delete its share
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id);
}
+113 -2
View File
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@@ -31,6 +32,7 @@ export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
@@ -42,16 +44,114 @@ export class ShareService {
}
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
const allPages = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
return { share, pageTree: pageList };
// Filter out restricted pages and maintain tree integrity
const filteredPages = await this.filterPublicPages(allPages, share.pageId);
return { share, pageTree: filteredPages };
} else {
return { share, pageTree: [] };
}
}
/**
* Filter pages for public share - exclude restricted pages.
* A page is included only if:
* 1. It has no page_access restriction AND
* 2. Its parent is also included (or it's the root)
*/
private async filterPublicPages<
T extends { id: string; parentPageId: string | null },
>(pages: T[], rootPageId: string): Promise<T[]> {
if (pages.length === 0) return [];
// Get all restricted page IDs
const restrictedIds =
await this.pagePermissionRepo.getRestrictedDescendantIds(rootPageId);
const restrictedSet = new Set(restrictedIds);
// Include pages that are NOT restricted and have valid parent chain
const includedIds = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (restrictedSet.has(page.id)) continue;
// Root page: include if not restricted
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Check if a specific page is accessible within a public share.
* A page is accessible if no page in its ancestor chain
* (from the page up to and including the share root) has a page_access restriction.
*/
private async isPagePubliclyAccessible(
pageId: string,
shareRootPageId: string,
): Promise<boolean> {
if (pageId === shareRootPageId) {
const hasRestriction = await this.db
.selectFrom('pageAccess')
.select('id')
.where('pageId', '=', pageId)
.executeTakeFirst();
return !hasRestriction;
}
// Get the depth from share root to the requested page
const shareToPage = await this.db
.selectFrom('pageHierarchy')
.select('depth')
.where('ancestorId', '=', shareRootPageId)
.where('descendantId', '=', pageId)
.executeTakeFirst();
if (!shareToPage) {
return false;
}
// Get all ancestor IDs in the chain from pageId to shareRootPageId
const chainPageIds = await this.db
.selectFrom('pageHierarchy')
.select('ancestorId')
.where('descendantId', '=', pageId)
.where('depth', '<=', shareToPage.depth)
.where('depth', '>', 0)
.execute();
const idsToCheck = [pageId, ...chainPageIds.map((c) => c.ancestorId)];
// Check if any page in the chain has a restriction
const hasRestricted = await this.db
.selectFrom('pageAccess')
.select('pageId')
.where('pageId', 'in', idsToCheck)
.executeTakeFirst();
return !hasRestricted;
}
async createShare(opts: {
authUserId: string;
workspaceId: string;
@@ -103,6 +203,17 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
// For descendant pages, verify the ancestor chain has no restrictions
if (share.level > 0) {
const isAccessible = await this.isPagePubliclyAccessible(
dto.pageId,
share.pageId,
);
if (!isAccessible) {
throw new NotFoundException('Shared page not found');
}
}
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
@@ -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,
@@ -0,0 +1,200 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_hierarchy')
.ifNotExists()
.addColumn('ancestor_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('descendant_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('depth', 'integer', (col) => col.notNull().defaultTo(0))
.addPrimaryKeyConstraint('page_hierarchy_pkey', [
'ancestor_id',
'descendant_id',
])
.execute();
// indexes
await db.schema
.createIndex('idx_page_hierarchy_descendant')
.ifNotExists()
.on('page_hierarchy')
.column('descendant_id')
.execute();
await db.schema
.createIndex('idx_page_hierarchy_ancestor_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['ancestor_id', 'depth'])
.execute();
await db.schema
.createIndex('idx_page_hierarchy_descendant_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['descendant_id', 'depth'])
.execute();
// rebuild function
await sql`
CREATE OR REPLACE FUNCTION rebuild_page_hierarchy()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE page_hierarchy;
WITH RECURSIVE page_tree AS (
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth
FROM pages WHERE deleted_at IS NULL
UNION ALL
SELECT pt.ancestor_id, p.id AS descendant_id, pt.depth + 1
FROM page_tree pt
JOIN pages p ON p.parent_page_id = pt.descendant_id
WHERE p.deleted_at IS NULL
)
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, descendant_id, depth FROM page_tree;
END;
$$;
`.execute(db);
// Create insert trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_insert()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_insert_trigger
AFTER INSERT ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_insert();
`.execute(db);
// Create update trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_update()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
subtree_ids UUID[];
BEGIN
-- Only process if parent_page_id or deleted_at changed
IF OLD.parent_page_id IS NOT DISTINCT FROM NEW.parent_page_id
AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
RETURN NEW;
END IF;
-- Handle soft-delete: remove from closure when deleted_at is set
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids);
RETURN NEW;
END IF;
-- Handle restore: rebuild closure when deleted_at is cleared
IF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END IF;
-- Skip if page is soft-deleted
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
-- Move operation: parent changed
-- Get all descendants of the moved page (including itself)
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
-- Delete old ancestor relationships (keep internal subtree links)
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids)
AND NOT (ancestor_id = ANY(subtree_ids));
-- Insert new ancestor relationships (if new parent exists)
IF NEW.parent_page_id IS NOT NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT
new_anc.ancestor_id,
sub.descendant_id,
new_anc.depth + sub.depth + 1
FROM page_hierarchy new_anc
CROSS JOIN page_hierarchy sub
WHERE new_anc.descendant_id = NEW.parent_page_id
AND sub.ancestor_id = NEW.id
AND sub.descendant_id = ANY(subtree_ids);
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_update_trigger
AFTER UPDATE ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_update();
`.execute(db);
await sql`SELECT rebuild_page_hierarchy()`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_update_trigger ON pages`.execute(
db,
);
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_insert_trigger ON pages`.execute(
db,
);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_update()`.execute(db);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_insert()`.execute(db);
await sql`DROP FUNCTION IF EXISTS rebuild_page_hierarchy()`.execute(db);
await db.schema.dropTable('page_hierarchy').ifExists().execute();
}
@@ -0,0 +1,93 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -152,4 +152,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
}
@@ -0,0 +1,692 @@
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';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable()
export class PagePermissionRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly groupUserRepo: GroupUserRepo,
) {}
async findPageAccessByPageId(
pageId: string,
trx?: KyselyTransaction,
): Promise<PageAccess | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pageAccess')
.selectAll()
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async insertPageAccess(
data: InsertablePageAccess,
trx?: KyselyTransaction,
): Promise<PageAccess> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pageAccess')
.values(data)
.returningAll()
.executeTakeFirst();
}
async deletePageAccess(pageId: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute();
}
async insertPagePermissions(
permissions: InsertablePagePermission[],
trx?: KyselyTransaction,
): Promise<void> {
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<PagePermission | undefined> {
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<PagePermission | undefined> {
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<void> {
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<void> {
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<void> {
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<number> {
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();
}
/**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Check if user can edit a page by verifying they have WRITER permission on ALL restricted ancestors.
*/
async canUserEditPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Get user's access level for a page, checking ALL restricted ancestors.
* Returns:
* - hasRestriction: whether page or any ancestor has restrictions
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
* - canEdit: user has writer permission on all restricted ancestors (always true if no restrictions)
*/
async getUserPageAccessLevel(
userId: string,
pageId: string,
): Promise<{ hasRestriction: boolean; canAccess: boolean; canEdit: boolean }> {
const result = await this.db
.selectFrom('pages')
.select((eb) => [
// hasRestriction: any ancestor has page_access entry
eb
.case()
.when(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.select('pageAccess.id')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id'),
),
)
.then(true)
.else(false)
.end()
.as('hasRestriction'),
// canAccess: no restricted ancestor without ANY permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canAccess'),
// canEdit: no restricted ancestor without WRITER permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
])
.where('pages.id', '=', pageId)
.executeTakeFirst();
return {
hasRestriction: Boolean(result?.hasRestriction),
canAccess: Boolean(result?.canAccess),
canEdit: Boolean(result?.canEdit),
};
}
/**
* Filter a list of page IDs to only those the user can access.
* Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency.
*/
async filterAccessiblePageIdsWithPermissions(
pageIds: string[],
userId: string,
): Promise<Array<{ id: string; canEdit: boolean }>> {
if (pageIds.length === 0) return [];
const results = await this.db
.selectFrom('pages')
.select('pages.id')
// Check if user lacks writer permission on any restricted ancestor
.select((eb) =>
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
)
.where('pages.id', 'in', pageIds)
// Filter: user must have access (any permission on all restricted ancestors)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => ({ id: r.id, canEdit: Boolean(r.canEdit) }));
}
/**
* Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed.
*/
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.select('pageAccess.id')
.where('pageHierarchy.descendantId', '=', pageId)
.executeTakeFirst();
return !!result;
}
/**
* Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation.
*/
async getParentIdsWithAccessibleChildren(
parentIds: string[],
userId: string,
): Promise<string[]> {
if (parentIds.length === 0) return [];
const results = await this.db
.selectFrom('pages as child')
.select('child.parentPageId')
.distinct()
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'child.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => r.parentPageId);
}
/**
* Check if any descendant of a page has restrictions that the user cannot access.
* Used to determine if includeSubPages can be enabled for sharing.
*/
async hasInaccessibleDescendants(
pageId: string,
userId: string,
): Promise<boolean> {
// Get all descendant page IDs (excluding the root page itself)
const descendants = await this.db
.selectFrom('pageHierarchy')
.select('descendantId')
.where('ancestorId', '=', pageId)
.where('depth', '>', 0)
.execute();
if (descendants.length === 0) {
return false;
}
const descendantIds = descendants.map((d) => d.descendantId);
// Check if any descendant has a restriction the user cannot access
const inaccessible = await this.db
.selectFrom('pageAccess')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageAccess.pageId', 'in', descendantIds)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !!inaccessible;
}
/**
* Get all descendant page IDs that have restrictions (page_access entries).
* Used to filter restricted pages from public share trees.
*/
async getRestrictedDescendantIds(pageId: string): Promise<string[]> {
const results = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.descendantId')
.select('pageHierarchy.descendantId')
.where('pageHierarchy.ancestorId', '=', pageId)
.execute();
return results.map((r) => r.descendantId);
}
}
+30
View File
@@ -197,6 +197,12 @@ export interface GroupUsers {
userId: string;
}
export interface PageHierarchy {
ancestorId: string;
descendantId: string;
depth: Generated<number>;
}
export interface PageHistory {
content: Json | null;
coverPhoto: string | null;
@@ -360,6 +366,27 @@ export interface Workspaces {
updatedAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -371,7 +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;
@@ -9,7 +9,10 @@ import {
FileTasks,
Groups,
GroupUsers,
PageAccess,
PageHierarchy,
PageHistory,
PagePermissions,
Pages,
Shares,
SpaceMembers,
@@ -32,8 +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;
@@ -3,6 +3,9 @@ import {
Attachments,
Comments,
Groups,
PageAccess as _PageAccess,
PageHierarchy as _PageHierarchy,
PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -131,3 +134,17 @@ export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
// 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<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
-1
View File
@@ -23,4 +23,3 @@ export * from "./lib/subpages";
export * from "./lib/highlight";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/columns";
-152
View File
@@ -1,152 +0,0 @@
import { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view';
import { gridResizingPluginKey } from './state';
import {
draggedWidth,
findBoundaryPosition,
getColumnInfoAtPos,
updateColumnNodeWidth,
} from './utils';
function updateActiveHandle(view: EditorView, value: number) {
view.dispatch(
view.state.tr.setMeta(gridResizingPluginKey, {
setHandle: value,
}),
);
}
export function handleMouseMove(
view: EditorView,
event: MouseEvent,
handleWidth: number,
): boolean {
const pluginState = gridResizingPluginKey.getState(view.state);
if (!pluginState) return false;
// TODO: limit call
if (pluginState.dragging) return false;
const boundaryPos = findBoundaryPosition(view, event, handleWidth);
if (boundaryPos !== pluginState.activeHandle) {
updateActiveHandle(view, boundaryPos);
}
return false;
}
export function handleMouseLeave(view: EditorView) {
const pluginState = gridResizingPluginKey.getState(view.state);
if (!pluginState) return false;
if (pluginState.activeHandle > -1 && !pluginState.dragging) {
updateActiveHandle(view, -1);
}
return false;
}
export function handleMouseDown(
view: EditorView,
event: MouseEvent,
columnMinWidth: number,
): boolean {
const pluginState = gridResizingPluginKey.getState(view.state);
if (!pluginState) return false;
if (pluginState.activeHandle === -1) return false;
if (pluginState.dragging) return false;
const columnInfo = getColumnInfoAtPos(view, pluginState.activeHandle);
if (!columnInfo) return false;
const { domWidth, $pos, node } = columnInfo;
const nodeAttrs = { ...(node.attrs || {}) };
const nodePos = $pos.before();
view.dispatch(
view.state.tr.setMeta(gridResizingPluginKey, {
setDragging: { startX: event.clientX, startWidth: domWidth },
}),
);
const win = view.dom.ownerDocument.defaultView || window;
const finish = (e: MouseEvent) => {
win.removeEventListener('mouseup', finish);
win.removeEventListener('mousemove', move);
const pluginState = gridResizingPluginKey.getState(view.state);
if (!pluginState?.dragging) return;
const finalWidth = draggedWidth(pluginState.dragging, e, columnMinWidth);
updateColumnNodeWidth(view, nodePos, nodeAttrs, finalWidth);
view.dispatch(
view.state.tr.setMeta(gridResizingPluginKey, {
setDragging: null,
}),
);
};
const move = (e: MouseEvent) => {
if (!e.buttons) {
finish(e);
return;
}
const pluginState = gridResizingPluginKey.getState(view.state);
if (!pluginState?.dragging) return;
const newWidth = draggedWidth(pluginState.dragging, e, columnMinWidth);
updateColumnNodeWidth(view, nodePos, nodeAttrs, newWidth);
};
win.addEventListener('mouseup', finish);
win.addEventListener('mousemove', move);
updateColumnNodeWidth(view, nodePos, nodeAttrs, domWidth);
event.preventDefault();
return true;
}
export function handleGridDecorations(
state: EditorState,
boundaryPos: number,
): DecorationSet {
const decorations = [];
const $pos = state.doc.resolve(boundaryPos);
if ($pos.nodeAfter !== null) {
const widget = document.createElement('div');
widget.className = 'grid-resize-handle';
const circleButton = document.createElement('div');
circleButton.className = 'circle-button';
widget.appendChild(circleButton);
const plusIcon = document.createElement('div');
plusIcon.className = 'plus';
circleButton.appendChild(plusIcon);
decorations.push(Decoration.widget(boundaryPos, widget));
}
return DecorationSet.create(state.doc, decorations);
}
export function handleMouseUp(view: EditorView, event: MouseEvent): boolean {
const div = event.target as HTMLElement;
if (!div) return false;
if (div.className !== 'circle-button' && div.className !== 'plus')
return false;
const column = div.closest('.prosemirror-column');
if (!column) return false;
const boundryPos = view.posAtDOM(column, 0);
if (!boundryPos) return false;
const $pos = view.state.doc.resolve(boundryPos);
const { state } = view;
view.dispatch(
state.tr.insert(
$pos.pos + $pos.parent.nodeSize - 1,
state.schema.nodes.column.create(
{
colWidth: 100,
},
state.schema.nodes.paragraph.create(),
),
),
);
return true;
}
@@ -1,4 +0,0 @@
export * from './schema';
export * from './resize';
export * from './keymap';
export * from './tiptap';
@@ -1,63 +0,0 @@
import { liftTarget, canSplit } from "@tiptap/pm/transform";
import { TextSelection, Command } from "@tiptap/pm/state";
import {
splitBlock,
chainCommands,
newlineInCode,
createParagraphNear,
} from "@tiptap/pm/commands";
import { keymap } from "@tiptap/pm/keymap";
import { ResolvedPos } from "@tiptap/pm/model";
function findParentColumn($pos: ResolvedPos) {
for (let depth = $pos.depth; depth > 0; depth--) {
const node = $pos.node(depth);
if (node.type.name === "column") {
return { node, depth };
}
}
return null;
}
export const liftEmptyBlock: Command = (state, dispatch) => {
const { $cursor } = state.selection as TextSelection;
if (!$cursor || $cursor.parent.content.size) return false;
if ("column" === $cursor.node($cursor.depth - 1).type.name) return false;
if ($cursor.depth > 1 && $cursor.after() != $cursor.end(-1)) {
const before = $cursor.before();
if (canSplit(state.doc, before)) {
if (dispatch) dispatch(state.tr.split(before).scrollIntoView());
return true;
}
}
const range = $cursor.blockRange(),
target = range && liftTarget(range);
if (target == null) return false;
if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView());
return true;
};
export const columnsKeymap = keymap({
Enter: chainCommands(
newlineInCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
),
"Mod-a": (state, dispatch, view) => {
const { selection } = state;
const { $from } = selection;
const found = findParentColumn($from);
if (found) {
const { depth } = found;
const start = $from.start(depth);
const end = $from.end(depth);
const tr = state.tr.setSelection(
TextSelection.create(state.doc, start, end),
);
if (dispatch) dispatch(tr);
return true;
}
return false;
},
} as { [key: string]: Command });
@@ -1,62 +0,0 @@
import { Plugin } from '@tiptap/pm/state';
import {
handleGridDecorations,
handleMouseDown,
handleMouseLeave,
handleMouseMove,
handleMouseUp,
} from './dom';
import { GridResizeState, gridResizingPluginKey } from './state';
export function gridResizingPlugin(options?: {
handleWidth?: number;
columnMinWidth?: number;
}) {
const handleWidth = options?.handleWidth ?? 2;
const columnMinWidth = options?.columnMinWidth ?? 50;
return new Plugin<GridResizeState>({
key: gridResizingPluginKey,
state: {
init: () => new GridResizeState(-1, false),
apply: (tr, prev) => {
return prev.apply(tr);
},
},
props: {
attributes: (state): Record<string, string> => {
const pluginState = gridResizingPluginKey.getState(state);
if (pluginState && pluginState.activeHandle > -1) {
return { class: 'resize-cursor' };
}
return {};
},
// The main event handlers
handleDOMEvents: {
mousemove: (view, event: MouseEvent) => {
return handleMouseMove(view, event, handleWidth);
},
mouseleave: (view) => {
return handleMouseLeave(view);
},
mousedown: (view, event: MouseEvent) => {
return handleMouseDown(view, event, columnMinWidth);
},
mouseup: (view, event: MouseEvent) => {
return handleMouseUp(view, event);
},
},
decorations: (state) => {
const pluginState = gridResizingPluginKey.getState(state);
if (!pluginState) return null;
if (pluginState.activeHandle === -1) return null;
return handleGridDecorations(state, pluginState.activeHandle);
},
},
});
}
@@ -1,51 +0,0 @@
import { NodeSpec } from '@tiptap/pm/model';
export type ColumnNodes = Record<'column' | 'column_container', NodeSpec>;
export function columnNodes(): ColumnNodes {
return {
column: {
group: 'block',
content: 'block+',
attrs: {
colWidth: { default: 200 },
},
parseDOM: [
{
tag: 'div.prosemirror-column',
getAttrs(dom) {
if (!(dom instanceof HTMLElement)) return false;
const width = dom.style.width.replace('px', '') || 200;
return {
colWidth: width,
};
},
},
],
toDOM(node) {
const { colWidth } = node.attrs;
const style = colWidth ? `width: ${colWidth}px;` : '';
return [
'div',
{
class: 'prosemirror-column',
style,
},
0,
];
},
},
column_container: {
group: 'block',
content: 'column+',
parseDOM: [
{
tag: 'div.prosemirror-column-container',
},
],
toDOM() {
return ['div', { class: 'prosemirror-column-container' }, 0];
},
},
};
}
@@ -1,33 +0,0 @@
import { PluginKey, Transaction } from '@tiptap/pm/state';
export const gridResizingPluginKey = new PluginKey<GridResizeState>(
'gridResizingPlugin',
);
export type Dragging = {
startX: number;
startWidth: number;
};
export class GridResizeState {
constructor(
public activeHandle: number,
public dragging: Dragging | false,
) {}
apply(tr: Transaction): GridResizeState {
const action = tr.getMeta(gridResizingPluginKey);
if (!action) return this;
if (typeof action.setHandle === 'number') {
return new GridResizeState(action.setHandle, false);
}
if (action.setDragging !== undefined) {
return new GridResizeState(this.activeHandle, action.setDragging);
}
if (this.activeHandle > -1 && tr.docChanged) {
// remap when doc changes
}
return this;
}
}
@@ -1,84 +0,0 @@
import { Node, mergeAttributes, Extension } from '@tiptap/core';
import { columnsKeymap } from './keymap';
import { gridResizingPlugin } from './resize';
const Column = Node.create({
name: 'column',
group: 'block',
content: 'block+',
addAttributes() {
return {
colWidth: {
default: 200,
parseHTML: (element) => {
const width = (element as HTMLElement).style.width.replace('px', '');
return Number(width) || 200;
},
renderHTML: (attributes) => {
const style = attributes.colWidth
? `width: ${attributes.colWidth}px;`
: '';
return { style };
},
},
};
},
parseHTML() {
return [
{
tag: 'div.prosemirror-column',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, { class: 'prosemirror-column' }),
0,
];
},
});
const ColumnContainer = Node.create({
name: 'column_container',
group: 'block',
content: 'column+',
parseHTML() {
return [
{
tag: 'div.prosemirror-column-container',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
class: 'prosemirror-column-container',
}),
0,
];
},
});
export const ColumnsExtension = Extension.create({
name: 'columns',
addExtensions() {
return [Column, ColumnContainer];
},
addProseMirrorPlugins() {
return [
gridResizingPlugin({ handleWidth: 2, columnMinWidth: 50 }),
columnsKeymap,
];
},
});
@@ -1,75 +0,0 @@
import { EditorView } from '@tiptap/pm/view';
import { type Dragging } from './state';
export function findBoundaryPosition(
view: EditorView,
event: MouseEvent,
handleWidth: number,
): number {
const gridDOM = event
.composedPath()
.find((el) =>
(el as HTMLElement).classList?.contains('prosemirror-column-container'),
) as HTMLElement | undefined;
if (!gridDOM) return -1;
const children = Array.from(gridDOM.children).filter((el) =>
el.classList.contains('prosemirror-column'),
);
for (let i = 0; i < children.length; i++) {
const colEl = children[i] as HTMLElement;
const rect = colEl.getBoundingClientRect();
if (
event.clientX >= rect.right - handleWidth - 2 &&
event.clientX <= rect.right + 10 + handleWidth
) {
const pos = view.posAtDOM(colEl, 0);
if (pos != null) {
return pos;
}
}
}
return -1;
}
export function draggedWidth(
dragging: Dragging,
event: MouseEvent,
minWidth: number,
): number {
const offset = event.clientX - dragging.startX;
return Math.max(minWidth, dragging.startWidth + offset);
}
export function updateColumnNodeWidth(
view: EditorView,
pos: number,
attrs: Record<string, string>,
width: number,
) {
view.dispatch(
view.state.tr.setNodeMarkup(pos, undefined, {
...attrs,
colWidth: width - 12 * 2,
}),
);
}
export function getColumnInfoAtPos(view: EditorView, boundaryPos: number) {
const $pos = view.state.doc.resolve(boundaryPos);
const node = $pos.parent;
if (!node || node.type.name !== 'column') return null;
const dom = view.domAtPos($pos.pos);
if (!dom.node) return null;
const columnEl =
dom.node instanceof HTMLElement
? dom.node
: (dom.node.childNodes[dom.offset] as HTMLElement);
const domWidth = columnEl.offsetWidth;
return { $pos, node, columnEl, domWidth };
}