From 4f52d32beff4c1038de7aabeffdf674f079b4403 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:57:02 +0100 Subject: [PATCH] - public attachment links - WIP --- apps/client/src/lib/api-client.ts | 1 + apps/client/src/pages/share/shared-page.tsx | 26 +- .../src/common/helpers/prosemirror/utils.ts | 39 ++ .../core/attachment/attachment.controller.ts | 611 ++++++++++-------- .../src/core/attachment/attachment.module.ts | 3 +- apps/server/src/core/auth/dto/jwt-payload.ts | 9 + .../src/core/auth/services/token.service.ts | 16 + .../casl/abilities/space-ability.factory.ts | 3 + .../casl/interfaces/space-ability.type.ts | 4 +- .../src/core/share/dto/update-page.dto.ts | 11 +- .../server/src/core/share/share.controller.ts | 54 +- apps/server/src/core/share/share.module.ts | 2 + apps/server/src/core/share/share.service.ts | 93 ++- apps/server/src/core/share/share.util.ts | 22 + apps/server/src/database/database.module.ts | 3 + .../migrations/20250408T191830-shares.ts | 15 +- .../src/database/repos/share/share.repo.ts | 141 ++++ apps/server/src/database/types/db.d.ts | 5 +- .../src/integrations/export/export.service.ts | 19 +- apps/server/src/integrations/export/utils.ts | 38 +- 20 files changed, 713 insertions(+), 402 deletions(-) create mode 100644 apps/server/src/core/share/share.util.ts create mode 100644 apps/server/src/database/repos/share/share.repo.ts diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index 35c9bc13..d4f61b05 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -26,6 +26,7 @@ api.interceptors.response.use( case 401: { const url = new URL(error.request.responseURL)?.pathname; if (url === "/api/auth/collab-token") return; + if (window.location.pathname.startsWith("/share/")) return; // Handle unauthorized error redirectToLogin(); diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 310a81b6..47e59cb8 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -37,20 +37,18 @@ export default function SharedPage() { } return ( - page && ( -
- - {`${page?.icon || ""} ${page?.title || t("untitled")}`} - +
+ + {`${page?.icon || ""} ${page?.title || t("untitled")}`} + - - - -
- ) + + + +
); } diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts index 9d9b5ebe..aaadcd56 100644 --- a/apps/server/src/common/helpers/prosemirror/utils.ts +++ b/apps/server/src/common/helpers/prosemirror/utils.ts @@ -1,5 +1,6 @@ import { Node } from '@tiptap/pm/model'; import { jsonToNode } from '../../../collaboration/collaboration.util'; +import { validate as isValidUUID } from 'uuid'; export interface MentionNode { id: string; @@ -56,3 +57,41 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] { } return pageMentionList as MentionNode[]; } + + +export function getProsemirrorContent(content: any) { + return ( + content ?? { + type: 'doc', + content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }], + } + ); +} + +export function isAttachmentNode(nodeType: string) { + const attachmentNodeTypes = [ + 'attachment', + 'image', + 'video', + 'excalidraw', + 'drawio', + ]; + return attachmentNodeTypes.includes(nodeType); +} + +export function getAttachmentIds(prosemirrorJson: any) { + const doc = jsonToNode(prosemirrorJson); + const attachmentIds = []; + + doc?.descendants((node: Node) => { + if (isAttachmentNode(node.type.name)) { + if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) { + if (!attachmentIds.includes(node.attrs.attachmentId)) { + attachmentIds.push(node.attrs.attachmentId); + } + } + } + }); + + return attachmentIds; +} \ No newline at end of file diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 4804fce6..160d950b 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -1,310 +1,373 @@ import { - BadRequestException, - Controller, - ForbiddenException, - Get, - HttpCode, - HttpStatus, - Logger, - NotFoundException, - Param, - Post, - Req, - Res, - UseGuards, - UseInterceptors, + BadRequestException, + Controller, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Logger, + NotFoundException, + Param, + Post, + Query, + Req, + Res, + UseGuards, + UseInterceptors, } from '@nestjs/common'; -import {AttachmentService} from './services/attachment.service'; -import {FastifyReply} from 'fastify'; -import {FileInterceptor} from '../../common/interceptors/file.interceptor'; +import { AttachmentService } from './services/attachment.service'; +import { FastifyReply } from 'fastify'; +import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import * as bytes from 'bytes'; -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 {User, Workspace} from '@docmost/db/types/entity.types'; -import {StorageService} from '../../integrations/storage/storage.service'; +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 { User, Workspace } from '@docmost/db/types/entity.types'; +import { StorageService } from '../../integrations/storage/storage.service'; import { - getAttachmentFolderPath, - validAttachmentTypes, + getAttachmentFolderPath, + validAttachmentTypes, } from './attachment.utils'; -import {getMimeType} from '../../common/helpers'; +import { getMimeType } from '../../common/helpers'; import { - AttachmentType, - inlineFileExtensions, - MAX_AVATAR_SIZE, + AttachmentType, + inlineFileExtensions, + MAX_AVATAR_SIZE, } from './attachment.constants'; import { - SpaceCaslAction, - SpaceCaslSubject, + SpaceCaslAction, + SpaceCaslSubject, } from '../casl/interfaces/space-ability.type'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { - WorkspaceCaslAction, - WorkspaceCaslSubject, + WorkspaceCaslAction, + WorkspaceCaslSubject, } from '../casl/interfaces/workspace-ability.type'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; -import {PageRepo} from '@docmost/db/repos/page/page.repo'; -import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo'; -import {validate as isValidUUID} from 'uuid'; -import {EnvironmentService} from "../../integrations/environment/environment.service"; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; +import { validate as isValidUUID } from 'uuid'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { TokenService } from '../auth/services/token.service'; +import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; @Controller() export class AttachmentController { - private readonly logger = new Logger(AttachmentController.name); + private readonly logger = new Logger(AttachmentController.name); - constructor( - private readonly attachmentService: AttachmentService, - private readonly storageService: StorageService, - private readonly workspaceAbility: WorkspaceAbilityFactory, - private readonly spaceAbility: SpaceAbilityFactory, - private readonly pageRepo: PageRepo, - private readonly attachmentRepo: AttachmentRepo, - private readonly environmentService: EnvironmentService, - ) { - } + constructor( + private readonly attachmentService: AttachmentService, + private readonly storageService: StorageService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly pageRepo: PageRepo, + private readonly attachmentRepo: AttachmentRepo, + private readonly environmentService: EnvironmentService, + private readonly tokenService: TokenService, + ) {} - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @Post('files/upload') - @UseInterceptors(FileInterceptor) - async uploadFile( - @Req() req: any, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ) { - const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit()); + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('files/upload') + @UseInterceptors(FileInterceptor) + async uploadFile( + @Req() req: any, + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit()); - let file = null; - try { - file = await req.file({ - limits: {fileSize: maxFileSize, fields: 3, files: 1}, - }); - } catch (err: any) { - this.logger.error(err.message); - if (err?.statusCode === 413) { - throw new BadRequestException( - `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`, - ); - } - } - - if (!file) { - throw new BadRequestException('Failed to upload file'); - } - - const pageId = file.fields?.pageId?.value; - - if (!pageId) { - throw new BadRequestException('PageId is required'); - } - - const page = await this.pageRepo.findById(pageId); - - if (!page) { - throw new NotFoundException('Page not found'); - } - - const spaceAbility = await this.spaceAbility.createForUser( - user, - page.spaceId, + let file = null; + try { + file = await req.file({ + limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + }); + } catch (err: any) { + this.logger.error(err.message); + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`, ); - if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } - - const spaceId = page.spaceId; - - const attachmentId = file.fields?.attachmentId?.value; - if (attachmentId && !isValidUUID(attachmentId)) { - throw new BadRequestException('Invalid attachment id'); - } - - try { - const fileResponse = await this.attachmentService.uploadFile({ - filePromise: file, - pageId: pageId, - spaceId: spaceId, - userId: user.id, - workspaceId: workspace.id, - attachmentId: attachmentId, - }); - - return res.send(fileResponse); - } catch (err: any) { - if (err?.statusCode === 413) { - const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; - this.logger.error(errMessage); - throw new BadRequestException(errMessage); - } - this.logger.error(err); - throw new BadRequestException('Error processing file upload.'); - } + } } - @UseGuards(JwtAuthGuard) - @Get('/files/:fileId/:fileName') - async getFile( - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - @Param('fileId') fileId: string, - @Param('fileName') fileName?: string, + if (!file) { + throw new BadRequestException('Failed to upload file'); + } + + const pageId = file.fields?.pageId?.value; + + if (!pageId) { + throw new BadRequestException('PageId is required'); + } + + const page = await this.pageRepo.findById(pageId); + + if (!page) { + 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(); + } + + const spaceId = page.spaceId; + + const attachmentId = file.fields?.attachmentId?.value; + if (attachmentId && !isValidUUID(attachmentId)) { + throw new BadRequestException('Invalid attachment id'); + } + + try { + const fileResponse = await this.attachmentService.uploadFile({ + filePromise: file, + pageId: pageId, + spaceId: spaceId, + userId: user.id, + workspaceId: workspace.id, + attachmentId: attachmentId, + }); + + return res.send(fileResponse); + } catch (err: any) { + if (err?.statusCode === 413) { + const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; + this.logger.error(errMessage); + throw new BadRequestException(errMessage); + } + this.logger.error(err); + throw new BadRequestException('Error processing file upload.'); + } + } + + @UseGuards(JwtAuthGuard) + @Get('/files/:fileId/:fileName') + async getFile( + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Param('fileId') fileId: string, + @Param('fileName') fileName?: string, + ) { + if (!isValidUUID(fileId)) { + throw new NotFoundException('Invalid file id'); + } + + const attachment = await this.attachmentRepo.findById(fileId); + if ( + !attachment || + attachment.workspaceId !== workspace.id || + !attachment.pageId || + !attachment.spaceId ) { - if (!isValidUUID(fileId)) { - throw new NotFoundException('Invalid file id'); - } + throw new NotFoundException(); + } - const attachment = await this.attachmentRepo.findById(fileId); - if ( - !attachment || - attachment.workspaceId !== workspace.id || - !attachment.pageId || - !attachment.spaceId - ) { - throw new NotFoundException(); - } + const spaceAbility = await this.spaceAbility.createForUser( + user, + attachment.spaceId, + ); - const spaceAbility = await this.spaceAbility.createForUser( - user, - attachment.spaceId, + if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + try { + const fileStream = await this.storageService.read(attachment.filePath); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': 'private, max-age=3600', + }); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, ); + } - if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { - throw new ForbiddenException(); - } + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } - try { - const fileStream = await this.storageService.read(attachment.filePath); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'private, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); - } catch (err) { - this.logger.error(err); - throw new NotFoundException('File not found'); - } + @Get('/files/public/:fileId/:fileName') + async getPublicFile( + @Res() res: FastifyReply, + @AuthWorkspace() workspace: Workspace, + @Param('fileId') fileId: string, + @Param('fileName') fileName?: string, + @Query('jwt') jwtToken?: string, + ) { + let jwtPayload: JwtAttachmentPayload = null; + try { + jwtPayload = await this.tokenService.verifyJwt( + jwtToken, + JwtType.ATTACHMENT, + ); + } catch (err) { + throw new BadRequestException( + 'Expired or invalid attachment access token', + ); } - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @Post('attachments/upload-image') - @UseInterceptors(FileInterceptor) - async uploadAvatarOrLogo( - @Req() req: any, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, + if ( + !isValidUUID(fileId) || + fileId !== jwtPayload.attachmentId || + jwtPayload.workspaceId !== workspace.id ) { - const maxFileSize = bytes(MAX_AVATAR_SIZE); - - let file = null; - try { - file = await req.file({ - limits: {fileSize: maxFileSize, fields: 3, files: 1}, - }); - } catch (err: any) { - if (err?.statusCode === 413) { - throw new BadRequestException( - `File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`, - ); - } - } - - if (!file) { - throw new BadRequestException('Invalid file upload'); - } - - const attachmentType = file.fields?.type?.value; - const spaceId = file.fields?.spaceId?.value; - - if (!attachmentType) { - throw new BadRequestException('attachment type is required'); - } - - if ( - !validAttachmentTypes.includes(attachmentType) || - attachmentType === AttachmentType.File - ) { - throw new BadRequestException('Invalid image attachment type'); - } - - if (attachmentType === AttachmentType.WorkspaceLogo) { - const ability = this.workspaceAbility.createForUser(user, workspace); - if ( - ability.cannot( - WorkspaceCaslAction.Manage, - WorkspaceCaslSubject.Settings, - ) - ) { - throw new ForbiddenException(); - } - } - - if (attachmentType === AttachmentType.SpaceLogo) { - if (!spaceId) { - throw new BadRequestException('spaceId is required'); - } - - const spaceAbility = await this.spaceAbility.createForUser(user, spaceId); - if ( - spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings) - ) { - throw new ForbiddenException(); - } - } - - try { - const fileResponse = await this.attachmentService.uploadImage( - file, - attachmentType, - user.id, - workspace.id, - spaceId, - ); - - return res.send(fileResponse); - } catch (err: any) { - this.logger.error(err); - throw new BadRequestException('Error processing file upload.'); - } + throw new NotFoundException('File not found'); } - @Get('attachments/img/:attachmentType/:fileName') - async getLogoOrAvatar( - @Res() res: FastifyReply, - @AuthWorkspace() workspace: Workspace, - @Param('attachmentType') attachmentType: AttachmentType, - @Param('fileName') fileName?: string, + const attachment = await this.attachmentRepo.findById(fileId); + if ( + !attachment || + attachment.workspaceId !== workspace.id || + !attachment.pageId || + !attachment.spaceId || + jwtPayload.pageId !== attachment.pageId ) { - if ( - !validAttachmentTypes.includes(attachmentType) || - attachmentType === AttachmentType.File - ) { - throw new BadRequestException('Invalid image attachment type'); - } - - const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; - - try { - const fileStream = await this.storageService.read(filePath); - res.headers({ - 'Content-Type': getMimeType(filePath), - 'Cache-Control': 'private, max-age=86400', - }); - return res.send(fileStream); - } catch (err) { - this.logger.error(err); - throw new NotFoundException('File not found'); - } + throw new NotFoundException('File not found'); } + + try { + const fileStream = await this.storageService.read(attachment.filePath); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': 'public, max-age=3600', + }); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, + ); + } + + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('attachments/upload-image') + @UseInterceptors(FileInterceptor) + async uploadAvatarOrLogo( + @Req() req: any, + @Res() res: FastifyReply, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const maxFileSize = bytes(MAX_AVATAR_SIZE); + + let file = null; + try { + file = await req.file({ + limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + }); + } catch (err: any) { + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`, + ); + } + } + + if (!file) { + throw new BadRequestException('Invalid file upload'); + } + + const attachmentType = file.fields?.type?.value; + const spaceId = file.fields?.spaceId?.value; + + if (!attachmentType) { + throw new BadRequestException('attachment type is required'); + } + + if ( + !validAttachmentTypes.includes(attachmentType) || + attachmentType === AttachmentType.File + ) { + throw new BadRequestException('Invalid image attachment type'); + } + + if (attachmentType === AttachmentType.WorkspaceLogo) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.Settings, + ) + ) { + throw new ForbiddenException(); + } + } + + if (attachmentType === AttachmentType.SpaceLogo) { + if (!spaceId) { + throw new BadRequestException('spaceId is required'); + } + + const spaceAbility = await this.spaceAbility.createForUser(user, spaceId); + if ( + spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + } + + try { + const fileResponse = await this.attachmentService.uploadImage( + file, + attachmentType, + user.id, + workspace.id, + spaceId, + ); + + return res.send(fileResponse); + } catch (err: any) { + this.logger.error(err); + throw new BadRequestException('Error processing file upload.'); + } + } + + @Get('attachments/img/:attachmentType/:fileName') + async getLogoOrAvatar( + @Res() res: FastifyReply, + @AuthWorkspace() workspace: Workspace, + @Param('attachmentType') attachmentType: AttachmentType, + @Param('fileName') fileName?: string, + ) { + if ( + !validAttachmentTypes.includes(attachmentType) || + attachmentType === AttachmentType.File + ) { + throw new BadRequestException('Invalid image attachment type'); + } + + const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; + + try { + const fileStream = await this.storageService.read(filePath); + res.headers({ + 'Content-Type': getMimeType(filePath), + 'Cache-Control': 'private, max-age=86400', + }); + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } } diff --git a/apps/server/src/core/attachment/attachment.module.ts b/apps/server/src/core/attachment/attachment.module.ts index 7dc47ed8..f80a2eb7 100644 --- a/apps/server/src/core/attachment/attachment.module.ts +++ b/apps/server/src/core/attachment/attachment.module.ts @@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module'; import { UserModule } from '../user/user.module'; import { WorkspaceModule } from '../workspace/workspace.module'; import { AttachmentProcessor } from './processors/attachment.processor'; +import { TokenModule } from '../auth/token.module'; @Module({ - imports: [StorageModule, UserModule, WorkspaceModule], + imports: [StorageModule, UserModule, WorkspaceModule, TokenModule], controllers: [AttachmentController], providers: [AttachmentService, AttachmentProcessor], }) diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index ad172b78..b9ce13c4 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -2,6 +2,7 @@ export enum JwtType { ACCESS = 'access', COLLAB = 'collab', EXCHANGE = 'exchange', + ATTACHMENT = 'attachment', } export type JwtPayload = { sub: string; @@ -21,3 +22,11 @@ export type JwtExchangePayload = { workspaceId: string; type: 'exchange'; }; + +export type JwtAttachmentPayload = { + attachmentId: string; + pageId: string; + workspaceId: string; + type: 'attachment'; +}; + diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index ad745290..963e8e65 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -6,6 +6,7 @@ import { import { JwtService } from '@nestjs/jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { + JwtAttachmentPayload, JwtCollabPayload, JwtExchangePayload, JwtPayload, @@ -59,6 +60,21 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn: '10s' }); } + async generateAttachmentToken(opts: { + attachmentId: string; + pageId: string; + workspaceId: string; + }): Promise { + const { attachmentId, pageId, workspaceId } = opts; + const payload: JwtAttachmentPayload = { + attachmentId: attachmentId, + pageId: pageId, + workspaceId: workspaceId, + type: JwtType.ATTACHMENT, + }; + return this.jwtService.sign(payload, { expiresIn: '1h' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index d2173383..53a57a0c 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -45,6 +45,7 @@ function buildSpaceAdminAbility() { can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings); can(SpaceCaslAction.Manage, SpaceCaslSubject.Member); can(SpaceCaslAction.Manage, SpaceCaslSubject.Page); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Share); return build(); } @@ -55,6 +56,7 @@ function buildSpaceWriterAbility() { can(SpaceCaslAction.Read, SpaceCaslSubject.Settings); can(SpaceCaslAction.Read, SpaceCaslSubject.Member); can(SpaceCaslAction.Manage, SpaceCaslSubject.Page); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Share); return build(); } @@ -65,5 +67,6 @@ function buildSpaceReaderAbility() { can(SpaceCaslAction.Read, SpaceCaslSubject.Settings); can(SpaceCaslAction.Read, SpaceCaslSubject.Member); can(SpaceCaslAction.Read, SpaceCaslSubject.Page); + can(SpaceCaslAction.Read, SpaceCaslSubject.Share); return build(); } diff --git a/apps/server/src/core/casl/interfaces/space-ability.type.ts b/apps/server/src/core/casl/interfaces/space-ability.type.ts index c927229b..d7801cab 100644 --- a/apps/server/src/core/casl/interfaces/space-ability.type.ts +++ b/apps/server/src/core/casl/interfaces/space-ability.type.ts @@ -9,9 +9,11 @@ export enum SpaceCaslSubject { Settings = 'settings', Member = 'member', Page = 'page', + Share = 'share', } export type ISpaceAbility = | [SpaceCaslAction, SpaceCaslSubject.Settings] | [SpaceCaslAction, SpaceCaslSubject.Member] - | [SpaceCaslAction, SpaceCaslSubject.Page]; + | [SpaceCaslAction, SpaceCaslSubject.Page] + | [SpaceCaslAction, SpaceCaslSubject.Share]; diff --git a/apps/server/src/core/share/dto/update-page.dto.ts b/apps/server/src/core/share/dto/update-page.dto.ts index 447c5ffa..30a0c38e 100644 --- a/apps/server/src/core/share/dto/update-page.dto.ts +++ b/apps/server/src/core/share/dto/update-page.dto.ts @@ -1,8 +1,7 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateShareDto } from './create-share.dto'; -import { IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; -export class UpdateShareDto extends PartialType(CreateShareDto) { - //@IsString() - //pageId: string; +export class UpdateShareDto { + @IsString() + @IsNotEmpty() + shareId: string; } diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index 10a6dee6..033a1cf8 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -23,6 +23,8 @@ import { ShareIdDto, ShareInfoDto } 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'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; @UseGuards(JwtAuthGuard) @Controller('shares') @@ -30,14 +32,27 @@ export class ShareController { constructor( private readonly shareService: ShareService, private readonly spaceAbility: SpaceAbilityFactory, + private readonly shareRepo: ShareRepo, private readonly pageRepo: PageRepo, ) {} + @HttpCode(HttpStatus.OK) + @Post('/') + async getShares( + @AuthUser() user: User, + @Body() pagination: PaginationOptions, + ) { + return this.shareRepo.getShares(user.id, pagination); + } + @Public() @HttpCode(HttpStatus.OK) @Post('/info') - async getPage(@Body() dto: ShareInfoDto) { - return this.shareService.getShare(dto); + async getShare( + @Body() dto: ShareInfoDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.shareService.getShare(dto, workspace.id); } @HttpCode(HttpStatus.OK) @@ -47,15 +62,14 @@ export class ShareController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - const page = await this.pageRepo.findById(createShareDto.pageId); - if (!page) { + 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.Page)) { + if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) { throw new ForbiddenException(); } @@ -63,43 +77,41 @@ export class ShareController { pageId: page.id, authUserId: user.id, workspaceId: workspace.id, + spaceId: page.spaceId, }); } @HttpCode(HttpStatus.OK) @Post('update') - async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) { - /* const page = await this.pageRepo.findById(updatePageDto.pageId); + async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) { + const share = await this.shareRepo.findById(updateShareDto.shareId); - if (!page) { - throw new NotFoundException('Page not found'); + if (!share) { + throw new NotFoundException('Share not found'); } - const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + const ability = await this.spaceAbility.createForUser(user, share.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) { throw new ForbiddenException(); } //return this.shareService.update(page, updatePageDto, user.id); - - */ } @HttpCode(HttpStatus.OK) @Post('delete') async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) { - /* const page = await this.pageRepo.findById(pageIdDto.pageId); + const share = await this.shareRepo.findById(shareIdDto.shareId); - if (!page) { - throw new NotFoundException('Page not found'); + if (!share) { + throw new NotFoundException('Share not found'); } - const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + const ability = await this.spaceAbility.createForUser(user, share.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) { throw new ForbiddenException(); } - - */ - // await this.shareService.forceDelete(pageIdDto.pageId); + + await this.shareRepo.deleteShare(share.id); } } diff --git a/apps/server/src/core/share/share.module.ts b/apps/server/src/core/share/share.module.ts index efef9b6d..2c0dd7c3 100644 --- a/apps/server/src/core/share/share.module.ts +++ b/apps/server/src/core/share/share.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ShareController } from './share.controller'; import { ShareService } from './share.service'; +import { TokenModule } from '../auth/token.module'; @Module({ + imports: [TokenModule], controllers: [ShareController], providers: [ShareService], exports: [ShareService], diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 87d69710..fb35cad3 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -1,48 +1,61 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ShareInfoDto } from './dto/share.dto'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { generateSlugId } 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, + isAttachmentNode, +} 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'; @Injectable() export class ShareService { constructor( + private readonly shareRepo: ShareRepo, private readonly pageRepo: PageRepo, @InjectKysely() private readonly db: KyselyDB, + private readonly tokenService: TokenService, ) {} async createShare(opts: { authUserId: string; workspaceId: string; pageId: string; + spaceId: string; }) { - const { authUserId, workspaceId, pageId } = opts; - - const slugId = generateSlugId(); // or custom slug - const share = this.db - .insertInto('shares') - .values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId }) - .returningAll() - .executeTakeFirst(); + const { authUserId, workspaceId, pageId, spaceId } = opts; + let share = null; + try { + const slugId = generateSlugId(); + share = await this.shareRepo.insertShare({ + slugId, + pageId, + workspaceId, + creatorId: authUserId, + spaceId: spaceId, + }); + } catch (err) { + throw new BadRequestException('Failed to share page'); + } return share; } - async getShare(dto: ShareInfoDto) { - // for now only single page share + async getShare(dto: ShareInfoDto, workspaceId: string) { + const share = await this.shareRepo.findById(dto.shareId); - // if only share Id is provided, return - - // if share id is pass with page id, what to do? - // if uuid is used, use Id - const share = await this.db - .selectFrom('shares') - .selectAll() - .where('slugId', '=', dto.shareId) - .executeTakeFirst(); - - if (!share) { + if (!share || share.workspaceId !== workspaceId) { throw new NotFoundException('Share not found'); } @@ -51,10 +64,7 @@ export class ShareService { includeCreator: true, }); - // cleanup json content - // remove comments mark - // make sure attachments work (videos, images, excalidraw, drawio) - // figure out internal links? + page.content = await this.updatePublicAttachments(page); if (!page) { throw new NotFoundException('Page not found'); @@ -62,4 +72,35 @@ export class ShareService { return page; } + + async updatePublicAttachments(page: Page): Promise { + const attachmentIds = getAttachmentIds(page.content); + 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(page.content as any); + + 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); + }); + + return doc.toJSON(); + } } diff --git a/apps/server/src/core/share/share.util.ts b/apps/server/src/core/share/share.util.ts new file mode 100644 index 00000000..e21f55aa --- /dev/null +++ b/apps/server/src/core/share/share.util.ts @@ -0,0 +1,22 @@ +import { Node } from '@tiptap/pm/model'; + +export function updateAttachmentAttr( + node: Node, + attr: 'src' | 'url', + token: string, +) { + const attrVal = node.attrs[attr]; + if ( + attrVal && + (attrVal.startsWith('/files') || attrVal.startsWith('/api/files')) + ) { + // @ts-ignore + node.attrs[attr] = updateAttachmentUrl(attrVal, token); + } +} + +function updateAttachmentUrl(src: string, jwtToken: string) { + const updatedSrc = src.replace('/files/', '/files/public/'); + const separator = updatedSrc.includes('?') ? '&' : '?'; + return `${updatedSrc}${separator}jwt=${jwtToken}`; +} diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 930bb59b..68c35dd3 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -24,6 +24,7 @@ import * as process from 'node:process'; import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, + ShareRepo ], exports: [ WorkspaceRepo, @@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); AttachmentRepo, UserTokenRepo, BacklinkRepo, + ShareRepo ], }) export class DatabaseModule diff --git a/apps/server/src/database/migrations/20250408T191830-shares.ts b/apps/server/src/database/migrations/20250408T191830-shares.ts index db745321..40c3e85d 100644 --- a/apps/server/src/database/migrations/20250408T191830-shares.ts +++ b/apps/server/src/database/migrations/20250408T191830-shares.ts @@ -7,15 +7,14 @@ export async function up(db: Kysely): Promise { col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) .addColumn('slug_id', 'varchar', (col) => col.notNull()) - .addColumn('page_id', 'varchar', (col) => col.notNull()) - .addColumn('include_sub_pages', 'varchar', (col) => col) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade'), + ) + .addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false)) .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) - - // pageSlug - - //.addColumn('space_id', 'uuid', (col) => - // col.references('spaces.id').onDelete('cascade').notNull(), - // ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull(), + ) .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id').onDelete('cascade').notNull(), ) diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts new file mode 100644 index 00000000..9d144e4e --- /dev/null +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { + InsertableShare, + Share, + UpdatableShare, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { validate as isValidUUID } from 'uuid'; +import { ExpressionBuilder } from 'kysely'; +import { DB } from '@docmost/db/types/db'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; + +@Injectable() +export class ShareRepo { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private spaceMemberRepo: SpaceMemberRepo, + ) {} + + private baseFields: Array = [ + 'id', + 'slugId', + 'pageId', + 'includeSubPages', + 'creatorId', + 'spaceId', + 'workspaceId', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + + async findById( + shareId: string, + opts?: { + includeCreator?: boolean; + withLock?: boolean; + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + let query = db.selectFrom('shares').select(this.baseFields); + + if (opts?.includeCreator) { + query = query.select((eb) => this.withCreator(eb)); + } + + if (opts?.withLock && opts?.trx) { + query = query.forUpdate(); + } + + if (isValidUUID(shareId)) { + query = query.where('id', '=', shareId); + } else { + query = query.where('slugId', '=', shareId); + } + + return query.executeTakeFirst(); + } + + async updateShare( + updatableShare: UpdatableShare, + shareId: string, + trx?: KyselyTransaction, + ) { + return dbOrTx(this.db, trx) + .updateTable('shares') + .set({ ...updatableShare, updatedAt: new Date() }) + .where(!isValidUUID(shareId) ? 'slugId' : 'id', '=', shareId) + .executeTakeFirst(); + } + + async insertShare( + insertableShare: InsertableShare, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('shares') + .values(insertableShare) + .returning(this.baseFields) + .executeTakeFirst(); + } + + async deleteShare(shareId: string): Promise { + let query = this.db.deleteFrom('shares'); + + if (isValidUUID(shareId)) { + query = query.where('id', '=', shareId); + } else { + query = query.where('slugId', '=', shareId); + } + + await query.execute(); + } + + async getShares(userId: string, pagination: PaginationOptions) { + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + + const query = this.db + .selectFrom('shares') + .select(this.baseFields) + .select((eb) => this.withSpace(eb)) + .select((eb) => this.withCreator(eb)) + .where('spaceId', 'in', userSpaceIds) + .orderBy('updatedAt', 'desc'); + + const hasEmptyIds = userSpaceIds.length === 0; + const result = executeWithPagination(query, { + page: pagination.page, + perPage: pagination.limit, + hasEmptyIds, + }); + + return result; + } + + withSpace(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug']) + .whereRef('spaces.id', '=', 'shares.spaceId'), + ).as('space'); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'shares.creatorId'), + ).as('creator'); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 2a634e01..fda12640 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -188,9 +188,10 @@ export interface Shares { creatorId: string | null; deletedAt: Timestamp | null; id: Generated; - includeSubPages: string | null; - pageId: string; + includeSubPages: Generated; + pageId: string | null; slugId: string; + spaceId: string; updatedAt: Generated; workspaceId: string; } diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 9cf33234..a5f00ba4 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -15,10 +15,8 @@ import { StorageService } from '../storage/storage.service'; import { buildTree, computeLocalPath, - getAttachmentIds, getExportExtension, getPageTitle, - getProsemirrorContent, PageExportTree, replaceInternalLinks, updateAttachmentUrls, @@ -29,6 +27,7 @@ import { EditorState } from '@tiptap/pm/state'; // eslint-disable-next-line @typescript-eslint/no-require-imports import slugify = require('@sindresorhus/slugify'); import { EnvironmentService } from '../environment/environment.service'; +import { getAttachmentIds, getProsemirrorContent } from '../../common/helpers/prosemirror/utils'; @Injectable() export class ExportService { @@ -76,8 +75,11 @@ export class ExportService { `; } - if (format === ExportFormat.Markdown) { - const newPageHtml = pageHtml.replace(/]*>[\s\S]*?<\/colgroup>/gmi, ''); + if (format === ExportFormat.Markdown) { + const newPageHtml = pageHtml.replace( + /]*>[\s\S]*?<\/colgroup>/gim, + '', + ); return turndown(newPageHtml); } @@ -260,14 +262,7 @@ export class ExportService { const pages = await this.db .selectFrom('pages') - .select([ - 'id', - 'slugId', - 'title', - 'creatorId', - 'spaceId', - 'workspaceId', - ]) + .select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId']) .select((eb) => this.pageRepo.withSpace(eb)) .where('id', 'in', pageMentionIds) .where('workspaceId', '=', workspaceId) diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index e296f194..20c9987c 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -4,6 +4,7 @@ import { Node } from '@tiptap/pm/model'; import { validate as isValidUUID } from 'uuid'; import * as path from 'path'; import { Page } from '@docmost/db/types/entity.types'; +import { isAttachmentNode } from '../../common/helpers/prosemirror/utils'; export type PageExportTree = Record; @@ -25,43 +26,6 @@ export function getPageTitle(title: string) { return title ? title : 'untitled'; } -export function getProsemirrorContent(content: any) { - return ( - content ?? { - type: 'doc', - content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }], - } - ); -} - -export function getAttachmentIds(prosemirrorJson: any) { - const doc = jsonToNode(prosemirrorJson); - const attachmentIds = []; - - doc?.descendants((node: Node) => { - if (isAttachmentNode(node.type.name)) { - if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) { - if (!attachmentIds.includes(node.attrs.attachmentId)) { - attachmentIds.push(node.attrs.attachmentId); - } - } - } - }); - - return attachmentIds; -} - -export function isAttachmentNode(nodeType: string) { - const attachmentNodeTypes = [ - 'attachment', - 'image', - 'video', - 'excalidraw', - 'drawio', - ]; - return attachmentNodeTypes.includes(nodeType); -} - export function updateAttachmentUrls(prosemirrorJson: any) { const doc = jsonToNode(prosemirrorJson);