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 {