import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { nanoIdGen } from '../../common/helpers'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { TokenService } from '../auth/services/token.service'; import { jsonToNode } from '../../collaboration/collaboration.util'; import { getAttachmentIds, getProsemirrorContent, isAttachmentNode, removeMarkTypeFromDoc, } from '../../common/helpers/prosemirror/utils'; import { Node } from '@tiptap/pm/model'; 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, @InjectKysely() private readonly db: KyselyDB, private readonly tokenService: TokenService, ) {} async getShareTree(shareId: string, workspaceId: string) { const share = await this.shareRepo.findById(shareId); if (!share || share.workspaceId !== workspaceId) { throw new NotFoundException('Share not found'); } if (share.includeSubPages) { const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { includeContent: false, }); return { share, pageTree: pageList }; } else { return { share, pageTree: [] }; } } async createShare(opts: { authUserId: string; workspaceId: string; page: Page; createShareDto: CreateShareDto; }) { const { authUserId, workspaceId, page, createShareDto } = opts; try { const shares = await this.shareRepo.findByPageId(page.id); if (shares) { return shares; } return await this.shareRepo.insertShare({ key: nanoIdGen().toLowerCase(), pageId: page.id, includeSubPages: createShareDto.includeSubPages || true, searchIndexing: createShareDto.searchIndexing || true, creatorId: authUserId, spaceId: page.spaceId, workspaceId, }); } catch (err) { this.logger.error(err); throw new BadRequestException('Failed to share page'); } } async updateShare(shareId: string, updateShareDto: UpdateShareDto) { try { return this.shareRepo.updateShare( { includeSubPages: updateShareDto.includeSubPages, searchIndexing: updateShareDto.searchIndexing, }, shareId, ); } catch (err) { this.logger.error(err); throw new BadRequestException('Failed to update share'); } } async getSharedPage(dto: ShareInfoDto, workspaceId: string) { const share = await this.getShareForPage(dto.pageId, workspaceId); if (!share) { throw new NotFoundException('Shared page not found'); } const page = await this.pageRepo.findById(dto.pageId, { includeContent: true, includeCreator: true, }); page.content = await this.updatePublicAttachments(page); if (!page) { throw new NotFoundException('Shared page not found'); } return { page, share }; } async getShareForPage(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', 'slugId', 'pages.title', 'pages.icon', 'parentPageId', sql`0`.as('level'), ]) .where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId) .unionAll((union) => union .selectFrom('pages as p') .select([ 'p.id', 'p.slugId', 'p.title', 'p.icon', '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.slugId as sharedPageSlugId', 'page_hierarchy.title as sharedPageTitle', 'page_hierarchy.icon as sharedPageIcon', 'page_hierarchy.level as level', 'shares.id', 'shares.key', 'shares.pageId', 'shares.includeSubPages', 'shares.searchIndexing', '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) { return undefined; } if (share.level === 1 && !share.includeSubPages) { // we can only show a page if its shared ancestor permits it return undefined; } return { id: share.id, key: share.key, includeSubPages: share.includeSubPages, searchIndexing: share.searchIndexing, pageId: share.pageId, creatorId: share.creatorId, spaceId: share.spaceId, workspaceId: share.workspaceId, createdAt: share.createdAt, level: share.level, sharedPage: { id: share.sharedPageId, slugId: share.sharedPageSlugId, title: share.sharedPageTitle, icon: share.sharedPageIcon, }, }; } async getShareAncestorPage( ancestorPageId: string, childPageId: string, ): Promise { let ancestor = null; try { ancestor = await this.db .withRecursive('page_ancestors', (db) => db .selectFrom('pages') .select([ 'id', 'slugId', 'title', 'parentPageId', 'spaceId', (eb) => eb .case() .when(eb.ref('id'), '=', ancestorPageId) .then(true) .else(false) .end() .as('found'), ]) .where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId) .unionAll((exp) => exp .selectFrom('pages as p') .select([ 'p.id', 'p.slugId', 'p.title', 'p.parentPageId', 'p.spaceId', (eb) => eb .case() .when(eb.ref('p.id'), '=', ancestorPageId) .then(true) .else(false) .end() .as('found'), ]) .innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id') // Continue recursing only when the target ancestor hasn't been found on that branch. .where('pa.found', '=', false), ), ) .selectFrom('page_ancestors') .selectAll() .where('found', '=', true) .limit(1) .executeTakeFirst(); } catch (err) { // empty } return ancestor; } async updatePublicAttachments(page: Page): Promise { const prosemirrorJson = getProsemirrorContent(page.content); const attachmentIds = getAttachmentIds(prosemirrorJson); const attachmentMap = new Map(); await Promise.all( attachmentIds.map(async (attachmentId: string) => { const token = await this.tokenService.generateAttachmentToken({ attachmentId, pageId: page.id, workspaceId: page.workspaceId, }); attachmentMap.set(attachmentId, token); }), ); const doc = jsonToNode(prosemirrorJson); doc?.descendants((node: Node) => { if (!isAttachmentNode(node.type.name)) return; const attachmentId = node.attrs.attachmentId; const token = attachmentMap.get(attachmentId); if (!token) return; updateAttachmentAttr(node, 'src', token); updateAttachmentAttr(node, 'url', token); }); const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment'); return removeCommentMarks.toJSON(); } }