mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 09:51:10 +10:00
WIP
This commit is contained in:
@ -1,9 +1,10 @@
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateShareDto {
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
includeSubPages: boolean;
|
||||
}
|
||||
|
||||
@ -17,12 +17,18 @@ export class SpaceIdDto {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export class ShareInfoDto extends ShareIdDto {
|
||||
export class ShareInfoDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
shareId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
pageId: string;
|
||||
|
||||
// @IsOptional()
|
||||
// @IsBoolean()
|
||||
// includeContent: boolean;
|
||||
}
|
||||
|
||||
export class SharePageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
@ -19,7 +20,7 @@ import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { ShareService } from './share.service';
|
||||
import { UpdateShareDto } from './dto/update-page.dto';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
|
||||
import { ShareIdDto, ShareInfoDto, SharePageIdDto } from './dto/share.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
@ -52,7 +53,32 @@ export class ShareController {
|
||||
@Body() dto: ShareInfoDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.shareService.getShare(dto, workspace.id);
|
||||
if (!dto.pageId && !dto.shareId) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return this.shareService.getSharedPage(dto, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/status')
|
||||
async getShareStatus(
|
||||
@Body() dto: SharePageIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
|
||||
if (!page || workspace.id !== page.workspaceId) {
|
||||
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();
|
||||
}
|
||||
|
||||
return this.shareService.getShareStatus(page.id, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -74,10 +100,10 @@ export class ShareController {
|
||||
}
|
||||
|
||||
return this.shareService.createShare({
|
||||
pageId: page.id,
|
||||
page,
|
||||
authUserId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
spaceId: page.spaceId,
|
||||
includeSubPages: createShareDto.includeSubPages,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ShareInfoDto } from './dto/share.dto';
|
||||
@ -20,9 +21,12 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { updateAttachmentAttr } from './share.util';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
private readonly logger = new Logger(ShareService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
@ -33,49 +37,39 @@ export class ShareService {
|
||||
async createShare(opts: {
|
||||
authUserId: string;
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
page: Page;
|
||||
includeSubPages: boolean;
|
||||
}) {
|
||||
const { authUserId, workspaceId, pageId, spaceId } = opts;
|
||||
let share = null;
|
||||
const { authUserId, workspaceId, page, includeSubPages } = opts;
|
||||
|
||||
try {
|
||||
const slugId = generateSlugId();
|
||||
share = await this.shareRepo.insertShare({
|
||||
slugId,
|
||||
pageId,
|
||||
workspaceId,
|
||||
const shares = await this.shareRepo.findByPageId(page.id);
|
||||
if (shares) {
|
||||
return shares;
|
||||
}
|
||||
|
||||
return await this.shareRepo.insertShare({
|
||||
key: generateSlugId(),
|
||||
pageId: page.id,
|
||||
includeSubPages: includeSubPages,
|
||||
creatorId: authUserId,
|
||||
spaceId: spaceId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Failed to share page');
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to create page');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async getShare(dto: ShareInfoDto, workspaceId: string) {
|
||||
const share = await this.shareRepo.findById(dto.shareId);
|
||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
||||
const share = await this.getShareStatus(dto.pageId, workspaceId);
|
||||
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Share not found');
|
||||
if (!share) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
let targetPageId = share.pageId;
|
||||
if (dto.pageId && dto.pageId !== share.pageId) {
|
||||
// Check if dto.pageId is a descendant of the shared page.
|
||||
const isDescendant = await this.getShareAncestorPage(
|
||||
share.pageId,
|
||||
dto.pageId,
|
||||
);
|
||||
if (isDescendant) {
|
||||
targetPageId = dto.pageId;
|
||||
} else {
|
||||
throw new NotFoundException(`Shared page not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(targetPageId, {
|
||||
const page = await this.pageRepo.findById(dto.pageId, {
|
||||
includeContent: true,
|
||||
includeCreator: true,
|
||||
});
|
||||
@ -89,6 +83,56 @@ export class ShareService {
|
||||
return page;
|
||||
}
|
||||
|
||||
async getShareStatus(pageId: string, workspaceId: string) {
|
||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||
const share = await this.db
|
||||
.withRecursive('page_hierarchy', (cte) =>
|
||||
cte
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'parentPageId', sql`0`.as('level')])
|
||||
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.parentPageId',
|
||||
// Increase the level by 1 for each ancestor.
|
||||
sql`ph.level + 1`.as('level'),
|
||||
])
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
|
||||
.select([
|
||||
'page_hierarchy.id as sharedPageId',
|
||||
'page_hierarchy.level as level',
|
||||
'shares.id as shareId',
|
||||
'shares.key as shareKey',
|
||||
'shares.includeSubPages as includeSubPages',
|
||||
'shares.creatorId',
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
'shares.createdAt',
|
||||
'shares.updatedAt',
|
||||
])
|
||||
.where('shares.id', 'is not', null)
|
||||
.orderBy('page_hierarchy.level', 'asc')
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!share || share.workspaceId != workspaceId) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
if (share.level === 1 && !share.includeSubPages) {
|
||||
// we can only show a page if its shared ancestor permits it
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async getShareAncestorPage(
|
||||
ancestorPageId: string,
|
||||
childPageId: string,
|
||||
|
||||
@ -6,11 +6,12 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('slug_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('key', 'varchar', (col) => col.notNull())
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
|
||||
.addColumn('search_indexing', 'boolean', (col) => col.defaultTo(true))
|
||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
@ -25,13 +26,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.addUniqueConstraint('shares_slug_id_unique', ['slug_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('shares_slug_id_idx')
|
||||
.on('shares')
|
||||
.column('slug_id')
|
||||
.addUniqueConstraint('shares_key_unique', ['key'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ export class ShareRepo {
|
||||
|
||||
private baseFields: Array<keyof Share> = [
|
||||
'id',
|
||||
'slugId',
|
||||
'key',
|
||||
'pageId',
|
||||
'includeSubPages',
|
||||
'creatorId',
|
||||
@ -58,12 +58,37 @@ export class ShareRepo {
|
||||
if (isValidUUID(shareId)) {
|
||||
query = query.where('id', '=', shareId);
|
||||
} else {
|
||||
query = query.where('slugId', '=', shareId);
|
||||
query = query.where('key', '=', shareId);
|
||||
}
|
||||
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
opts?: {
|
||||
includeCreator?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Share> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
let query = db
|
||||
.selectFrom('shares')
|
||||
.select(this.baseFields)
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
if (opts?.includeCreator) {
|
||||
query = query.select((eb) => this.withCreator(eb));
|
||||
}
|
||||
|
||||
if (opts?.withLock && opts?.trx) {
|
||||
query = query.forUpdate();
|
||||
}
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateShare(
|
||||
updatableShare: UpdatableShare,
|
||||
shareId: string,
|
||||
@ -72,7 +97,7 @@ export class ShareRepo {
|
||||
return dbOrTx(this.db, trx)
|
||||
.updateTable('shares')
|
||||
.set({ ...updatableShare, updatedAt: new Date() })
|
||||
.where(!isValidUUID(shareId) ? 'slugId' : 'id', '=', shareId)
|
||||
.where(!isValidUUID(shareId) ? 'key' : 'id', '=', shareId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@ -94,7 +119,7 @@ export class ShareRepo {
|
||||
if (isValidUUID(shareId)) {
|
||||
query = query.where('id', '=', shareId);
|
||||
} else {
|
||||
query = query.where('slugId', '=', shareId);
|
||||
query = query.where('key', '=', shareId);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
|
||||
3
apps/server/src/database/types/db.d.ts
vendored
3
apps/server/src/database/types/db.d.ts
vendored
@ -189,8 +189,9 @@ export interface Shares {
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
includeSubPages: Generated<boolean | null>;
|
||||
key: string;
|
||||
pageId: string | null;
|
||||
slugId: string;
|
||||
searchIndexing: Generated<boolean>;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
|
||||
Reference in New Issue
Block a user