mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 20:11:09 +10:00
- public attachment links
- WIP
This commit is contained in:
@ -26,6 +26,7 @@ api.interceptors.response.use(
|
|||||||
case 401: {
|
case 401: {
|
||||||
const url = new URL(error.request.responseURL)?.pathname;
|
const url = new URL(error.request.responseURL)?.pathname;
|
||||||
if (url === "/api/auth/collab-token") return;
|
if (url === "/api/auth/collab-token") return;
|
||||||
|
if (window.location.pathname.startsWith("/share/")) return;
|
||||||
|
|
||||||
// Handle unauthorized error
|
// Handle unauthorized error
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
|
|||||||
@ -37,7 +37,6 @@ export default function SharedPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
page && (
|
|
||||||
<div>
|
<div>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
|
||||||
@ -51,6 +50,5 @@ export default function SharedPage() {
|
|||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||||
|
import { validate as isValidUUID } from 'uuid';
|
||||||
|
|
||||||
export interface MentionNode {
|
export interface MentionNode {
|
||||||
id: string;
|
id: string;
|
||||||
@ -56,3 +57,41 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
|||||||
}
|
}
|
||||||
return pageMentionList as 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;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@ -46,7 +47,9 @@ import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import {EnvironmentService} from "../../integrations/environment/environment.service";
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
import { TokenService } from '../auth/services/token.service';
|
||||||
|
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@ -60,8 +63,8 @@ export class AttachmentController {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly attachmentRepo: AttachmentRepo,
|
private readonly attachmentRepo: AttachmentRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
) {
|
private readonly tokenService: TokenService,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -195,6 +198,66 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isValidUUID(fileId) ||
|
||||||
|
fileId !== jwtPayload.attachmentId ||
|
||||||
|
jwtPayload.workspaceId !== workspace.id
|
||||||
|
) {
|
||||||
|
throw new NotFoundException('File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await this.attachmentRepo.findById(fileId);
|
||||||
|
if (
|
||||||
|
!attachment ||
|
||||||
|
attachment.workspaceId !== workspace.id ||
|
||||||
|
!attachment.pageId ||
|
||||||
|
!attachment.spaceId ||
|
||||||
|
jwtPayload.pageId !== attachment.pageId
|
||||||
|
) {
|
||||||
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('attachments/upload-image')
|
@Post('attachments/upload-image')
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
|||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||||
import { AttachmentProcessor } from './processors/attachment.processor';
|
import { AttachmentProcessor } from './processors/attachment.processor';
|
||||||
|
import { TokenModule } from '../auth/token.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorageModule, UserModule, WorkspaceModule],
|
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
|
||||||
controllers: [AttachmentController],
|
controllers: [AttachmentController],
|
||||||
providers: [AttachmentService, AttachmentProcessor],
|
providers: [AttachmentService, AttachmentProcessor],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export enum JwtType {
|
|||||||
ACCESS = 'access',
|
ACCESS = 'access',
|
||||||
COLLAB = 'collab',
|
COLLAB = 'collab',
|
||||||
EXCHANGE = 'exchange',
|
EXCHANGE = 'exchange',
|
||||||
|
ATTACHMENT = 'attachment',
|
||||||
}
|
}
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@ -21,3 +22,11 @@ export type JwtExchangePayload = {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: 'exchange';
|
type: 'exchange';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JwtAttachmentPayload = {
|
||||||
|
attachmentId: string;
|
||||||
|
pageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
type: 'attachment';
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
@ -59,6 +60,21 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '10s' });
|
return this.jwtService.sign(payload, { expiresIn: '10s' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateAttachmentToken(opts: {
|
||||||
|
attachmentId: string;
|
||||||
|
pageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
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) {
|
async verifyJwt(token: string, tokenType: string) {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: this.environmentService.getAppSecret(),
|
secret: this.environmentService.getAppSecret(),
|
||||||
|
|||||||
@ -45,6 +45,7 @@ function buildSpaceAdminAbility() {
|
|||||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
|
||||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
|
||||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||||
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ function buildSpaceWriterAbility() {
|
|||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||||
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,5 +67,6 @@ function buildSpaceReaderAbility() {
|
|||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
|
||||||
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,9 +9,11 @@ export enum SpaceCaslSubject {
|
|||||||
Settings = 'settings',
|
Settings = 'settings',
|
||||||
Member = 'member',
|
Member = 'member',
|
||||||
Page = 'page',
|
Page = 'page',
|
||||||
|
Share = 'share',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ISpaceAbility =
|
export type ISpaceAbility =
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Page];
|
| [SpaceCaslAction, SpaceCaslSubject.Page]
|
||||||
|
| [SpaceCaslAction, SpaceCaslSubject.Share];
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { CreateShareDto } from './create-share.dto';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateShareDto extends PartialType(CreateShareDto) {
|
export class UpdateShareDto {
|
||||||
//@IsString()
|
@IsString()
|
||||||
//pageId: string;
|
@IsNotEmpty()
|
||||||
|
shareId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('shares')
|
@Controller('shares')
|
||||||
@ -30,14 +32,27 @@ export class ShareController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly shareService: ShareService,
|
private readonly shareService: ShareService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/')
|
||||||
|
async getShares(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Body() pagination: PaginationOptions,
|
||||||
|
) {
|
||||||
|
return this.shareRepo.getShares(user.id, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/info')
|
@Post('/info')
|
||||||
async getPage(@Body() dto: ShareInfoDto) {
|
async getShare(
|
||||||
return this.shareService.getShare(dto);
|
@Body() dto: ShareInfoDto,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return this.shareService.getShare(dto, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -47,15 +62,14 @@ export class ShareController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(createShareDto.pageId);
|
const page = await this.pageRepo.findById(createShareDto.pageId);
|
||||||
|
|
||||||
if (!page) {
|
if (!page || workspace.id !== page.workspaceId) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
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();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,43 +77,41 @@ export class ShareController {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
authUserId: user.id,
|
authUserId: user.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
spaceId: page.spaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) {
|
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
|
||||||
/* const page = await this.pageRepo.findById(updatePageDto.pageId);
|
const share = await this.shareRepo.findById(updateShareDto.shareId);
|
||||||
|
|
||||||
if (!page) {
|
if (!share) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
//return this.shareService.update(page, updatePageDto, user.id);
|
//return this.shareService.update(page, updatePageDto, user.id);
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
|
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) {
|
if (!share) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
await this.shareRepo.deleteShare(share.id);
|
||||||
// await this.shareService.forceDelete(pageIdDto.pageId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ShareController } from './share.controller';
|
import { ShareController } from './share.controller';
|
||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
|
import { TokenModule } from '../auth/token.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [TokenModule],
|
||||||
controllers: [ShareController],
|
controllers: [ShareController],
|
||||||
providers: [ShareService],
|
providers: [ShareService],
|
||||||
exports: [ShareService],
|
exports: [ShareService],
|
||||||
|
|||||||
@ -1,48 +1,61 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ShareInfoDto } from './dto/share.dto';
|
import { ShareInfoDto } from './dto/share.dto';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateSlugId } from '../../common/helpers';
|
import { generateSlugId } from '../../common/helpers';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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()
|
@Injectable()
|
||||||
export class ShareService {
|
export class ShareService {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createShare(opts: {
|
async createShare(opts: {
|
||||||
authUserId: string;
|
authUserId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
}) {
|
}) {
|
||||||
const { authUserId, workspaceId, pageId } = opts;
|
const { authUserId, workspaceId, pageId, spaceId } = opts;
|
||||||
|
let share = null;
|
||||||
const slugId = generateSlugId(); // or custom slug
|
try {
|
||||||
const share = this.db
|
const slugId = generateSlugId();
|
||||||
.insertInto('shares')
|
share = await this.shareRepo.insertShare({
|
||||||
.values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId })
|
slugId,
|
||||||
.returningAll()
|
pageId,
|
||||||
.executeTakeFirst();
|
workspaceId,
|
||||||
|
creatorId: authUserId,
|
||||||
|
spaceId: spaceId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException('Failed to share page');
|
||||||
|
}
|
||||||
|
|
||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShare(dto: ShareInfoDto) {
|
async getShare(dto: ShareInfoDto, workspaceId: string) {
|
||||||
// for now only single page share
|
const share = await this.shareRepo.findById(dto.shareId);
|
||||||
|
|
||||||
// if only share Id is provided, return
|
if (!share || share.workspaceId !== workspaceId) {
|
||||||
|
|
||||||
// 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) {
|
|
||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,10 +64,7 @@ export class ShareService {
|
|||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// cleanup json content
|
page.content = await this.updatePublicAttachments(page);
|
||||||
// remove comments mark
|
|
||||||
// make sure attachments work (videos, images, excalidraw, drawio)
|
|
||||||
// figure out internal links?
|
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
@ -62,4 +72,35 @@ export class ShareService {
|
|||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePublicAttachments(page: Page): Promise<any> {
|
||||||
|
const attachmentIds = getAttachmentIds(page.content);
|
||||||
|
const attachmentMap = new Map<string, string>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/server/src/core/share/share.util.ts
Normal file
22
apps/server/src/core/share/share.util.ts
Normal file
@ -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}`;
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import * as process from 'node:process';
|
|||||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.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
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
|
ShareRepo
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
BacklinkRepo,
|
BacklinkRepo,
|
||||||
|
ShareRepo
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule
|
export class DatabaseModule
|
||||||
|
|||||||
@ -7,15 +7,14 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
)
|
)
|
||||||
.addColumn('slug_id', 'varchar', (col) => col.notNull())
|
.addColumn('slug_id', 'varchar', (col) => col.notNull())
|
||||||
.addColumn('page_id', 'varchar', (col) => col.notNull())
|
.addColumn('page_id', 'uuid', (col) =>
|
||||||
.addColumn('include_sub_pages', 'varchar', (col) => 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'))
|
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||||
|
.addColumn('space_id', 'uuid', (col) =>
|
||||||
// pageSlug
|
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||||
|
)
|
||||||
//.addColumn('space_id', 'uuid', (col) =>
|
|
||||||
// col.references('spaces.id').onDelete('cascade').notNull(),
|
|
||||||
// )
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||||
)
|
)
|
||||||
|
|||||||
141
apps/server/src/database/repos/share/share.repo.ts
Normal file
141
apps/server/src/database/repos/share/share.repo.ts
Normal file
@ -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<keyof Share> = [
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'pageId',
|
||||||
|
'includeSubPages',
|
||||||
|
'creatorId',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'deletedAt',
|
||||||
|
];
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
shareId: string,
|
||||||
|
opts?: {
|
||||||
|
includeCreator?: boolean;
|
||||||
|
withLock?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
): Promise<Share> {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
|
let query = db.selectFrom('shares').select(this.baseFields);
|
||||||
|
|
||||||
|
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<Share> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.insertInto('shares')
|
||||||
|
.values(insertableShare)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteShare(shareId: string): Promise<void> {
|
||||||
|
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<DB, 'shares'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
||||||
|
.whereRef('spaces.id', '=', 'shares.spaceId'),
|
||||||
|
).as('space');
|
||||||
|
}
|
||||||
|
|
||||||
|
withCreator(eb: ExpressionBuilder<DB, 'shares'>) {
|
||||||
|
return jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||||
|
.whereRef('users.id', '=', 'shares.creatorId'),
|
||||||
|
).as('creator');
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/server/src/database/types/db.d.ts
vendored
5
apps/server/src/database/types/db.d.ts
vendored
@ -188,9 +188,10 @@ export interface Shares {
|
|||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
includeSubPages: string | null;
|
includeSubPages: Generated<boolean | null>;
|
||||||
pageId: string;
|
pageId: string | null;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
|
spaceId: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,8 @@ import { StorageService } from '../storage/storage.service';
|
|||||||
import {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
computeLocalPath,
|
computeLocalPath,
|
||||||
getAttachmentIds,
|
|
||||||
getExportExtension,
|
getExportExtension,
|
||||||
getPageTitle,
|
getPageTitle,
|
||||||
getProsemirrorContent,
|
|
||||||
PageExportTree,
|
PageExportTree,
|
||||||
replaceInternalLinks,
|
replaceInternalLinks,
|
||||||
updateAttachmentUrls,
|
updateAttachmentUrls,
|
||||||
@ -29,6 +27,7 @@ import { EditorState } from '@tiptap/pm/state';
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import slugify = require('@sindresorhus/slugify');
|
import slugify = require('@sindresorhus/slugify');
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { getAttachmentIds, getProsemirrorContent } from '../../common/helpers/prosemirror/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@ -77,7 +76,10 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (format === ExportFormat.Markdown) {
|
if (format === ExportFormat.Markdown) {
|
||||||
const newPageHtml = pageHtml.replace(/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gmi, '');
|
const newPageHtml = pageHtml.replace(
|
||||||
|
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
||||||
|
'',
|
||||||
|
);
|
||||||
return turndown(newPageHtml);
|
return turndown(newPageHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,14 +262,7 @@ export class ExportService {
|
|||||||
|
|
||||||
const pages = await this.db
|
const pages = await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
|
||||||
'id',
|
|
||||||
'slugId',
|
|
||||||
'title',
|
|
||||||
'creatorId',
|
|
||||||
'spaceId',
|
|
||||||
'workspaceId',
|
|
||||||
])
|
|
||||||
.select((eb) => this.pageRepo.withSpace(eb))
|
.select((eb) => this.pageRepo.withSpace(eb))
|
||||||
.where('id', 'in', pageMentionIds)
|
.where('id', 'in', pageMentionIds)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Node } from '@tiptap/pm/model';
|
|||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
|
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
|
||||||
|
|
||||||
export type PageExportTree = Record<string, Page[]>;
|
export type PageExportTree = Record<string, Page[]>;
|
||||||
|
|
||||||
@ -25,43 +26,6 @@ export function getPageTitle(title: string) {
|
|||||||
return title ? title : 'untitled';
|
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) {
|
export function updateAttachmentUrls(prosemirrorJson: any) {
|
||||||
const doc = jsonToNode(prosemirrorJson);
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user