mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 00:02:30 +10:00
editor improvements
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
This commit is contained in:
@ -10,7 +10,23 @@ import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { TrailingNode, Comment } from '@docmost/editor-ext';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import {
|
||||
Callout,
|
||||
Comment,
|
||||
Details,
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
MathBlock,
|
||||
MathInline,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TrailingNode,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateHTML, generateJSON } from '@tiptap/html';
|
||||
import { generateText, JSONContent } from '@tiptap/core';
|
||||
|
||||
@ -29,6 +45,19 @@ export const tiptapExtensions = [
|
||||
TrailingNode,
|
||||
TextStyle,
|
||||
Color,
|
||||
MathInline,
|
||||
MathBlock,
|
||||
Details,
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Youtube,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
Callout,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: JSONContent) {
|
||||
|
||||
@ -77,7 +77,13 @@ export class PersistenceExtension implements Extension {
|
||||
const tiptapJson = TiptapTransformer.fromYdoc(document, 'default');
|
||||
const ydocState = Buffer.from(Y.encodeStateAsUpdate(document));
|
||||
|
||||
const textContent = jsonToText(tiptapJson);
|
||||
let textContent = null;
|
||||
|
||||
try {
|
||||
textContent = jsonToText(tiptapJson);
|
||||
} catch (err) {
|
||||
this.logger.warn('jsonToText' + err?.['message']);
|
||||
}
|
||||
|
||||
try {
|
||||
let page = null;
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export const AuthUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
if (!request?.user?.user) {
|
||||
throw new BadRequestException('Invalid User');
|
||||
}
|
||||
|
||||
return request.user.user;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export const AuthWorkspace = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user.workspace;
|
||||
const workspace = request.raw?.workspace ?? request?.user?.workspace;
|
||||
|
||||
if (!workspace) {
|
||||
throw new BadRequestException('Invalid workspace');
|
||||
}
|
||||
|
||||
return workspace;
|
||||
},
|
||||
);
|
||||
|
||||
@ -22,7 +22,9 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
return next();
|
||||
}
|
||||
|
||||
// TODO: unify
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else if (this.environmentService.isCloud()) {
|
||||
const header = req.headers.host;
|
||||
const subdomain = header.split('.')[0];
|
||||
@ -34,6 +36,7 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
}
|
||||
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@ -5,8 +5,15 @@ export enum AttachmentType {
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg', 'gif'];
|
||||
export const MAX_AVATAR_SIZE = '5MB';
|
||||
|
||||
export const validFileExtensions = ['.jpg', '.png', '.jpeg', '.pdf'];
|
||||
export const InlineFileExtensions = [
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.jpeg',
|
||||
'.pdf',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
];
|
||||
export const MAX_FILE_SIZE = '20MB';
|
||||
|
||||
@ -43,8 +43,12 @@ import {
|
||||
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 { Public } from '../../common/decorators/public.decorator';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
|
||||
@Controller('attachments')
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
private readonly logger = new Logger(AttachmentController.name);
|
||||
|
||||
@ -53,11 +57,13 @@ export class AttachmentController {
|
||||
private readonly storageService: StorageService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('upload-file')
|
||||
@Post('files/upload')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadFile(
|
||||
@Req() req: any,
|
||||
@ -70,9 +76,10 @@ export class AttachmentController {
|
||||
let file = null;
|
||||
try {
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
limits: { fileSize: maxFileSize, fields: 2, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(err.message);
|
||||
if (err?.statusCode === 413) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
|
||||
@ -81,42 +88,85 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('Invalid file upload');
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
|
||||
const pageId = file.fields?.pageId.value;
|
||||
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;
|
||||
|
||||
try {
|
||||
const fileResponse = await this.attachmentService.uploadFile(
|
||||
file,
|
||||
pageId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
const fileResponse = await this.attachmentService.uploadFile({
|
||||
filePromise: file,
|
||||
pageId: pageId,
|
||||
spaceId: spaceId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err: any) {
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/:fileId/:fileName')
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/files/:fileId/:fileName')
|
||||
async getFile(
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
//@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Param('fileId') fileId: string,
|
||||
@Param('fileName') fileName?: string,
|
||||
) {
|
||||
// TODO
|
||||
if (!isValidUUID(fileId)) {
|
||||
throw new NotFoundException('Invalid file id');
|
||||
}
|
||||
|
||||
const attachment = await this.attachmentRepo.findById(fileId);
|
||||
if (attachment.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!attachment || !attachment.pageId) {
|
||||
throw new NotFoundException('File record not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(attachment.filePath);
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(attachment.filePath),
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('upload-image')
|
||||
@Post('attachments/upload-image')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadAvatarOrLogo(
|
||||
@Req() req: any,
|
||||
@ -198,19 +248,13 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/img/:attachmentType/:fileName')
|
||||
@Get('attachments/img/:attachmentType/:fileName')
|
||||
async getLogoOrAvatar(
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Param('attachmentType') attachmentType: AttachmentType,
|
||||
@Param('fileName') fileName?: string,
|
||||
) {
|
||||
const workspaceId = req.raw?.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException('Invalid workspace');
|
||||
}
|
||||
|
||||
if (
|
||||
!validAttachmentTypes.includes(attachmentType) ||
|
||||
attachmentType === AttachmentType.File
|
||||
@ -218,12 +262,12 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
const buildFilePath = `${getAttachmentFolderPath(attachmentType, workspaceId)}/${fileName}`;
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(buildFilePath);
|
||||
const fileStream = await this.storageService.read(filePath);
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(buildFilePath),
|
||||
'Content-Type': getMimeType(filePath),
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
|
||||
@ -26,7 +26,7 @@ export async function prepareFile(
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
||||
const fileName = `${rand}_${sanitizedFilename}`;
|
||||
const fileName = sanitizedFilename.slice(0, 255);
|
||||
const fileSize = buffer.length;
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
|
||||
|
||||
@ -9,11 +9,7 @@ import {
|
||||
} from '../attachment.utils';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import {
|
||||
AttachmentType,
|
||||
validFileExtensions,
|
||||
validImageExtensions,
|
||||
} from '../attachment.constants';
|
||||
import { AttachmentType, validImageExtensions } from '../attachment.constants';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { Attachment } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@ -34,31 +30,36 @@ export class AttachmentService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async uploadFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
async uploadFile(opts: {
|
||||
filePromise: Promise<MultipartFile>;
|
||||
pageId: string;
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validFileExtensions);
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${preparedFile.fileName}`;
|
||||
const attachmentId = uuid4();
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||
|
||||
let attachment: Attachment = null;
|
||||
try {
|
||||
attachment = await this.saveAttachment({
|
||||
attachmentId,
|
||||
preparedFile,
|
||||
filePath,
|
||||
type: AttachmentType.File,
|
||||
userId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
});
|
||||
} catch (err) {
|
||||
// delete uploaded file on error
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return attachment;
|
||||
@ -175,6 +176,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
async saveAttachment(opts: {
|
||||
attachmentId?: string;
|
||||
preparedFile: PreparedFile;
|
||||
filePath: string;
|
||||
type: AttachmentType;
|
||||
@ -185,6 +187,7 @@ export class AttachmentService {
|
||||
trx?: KyselyTransaction;
|
||||
}): Promise<Attachment> {
|
||||
const {
|
||||
attachmentId,
|
||||
preparedFile,
|
||||
filePath,
|
||||
type,
|
||||
@ -196,6 +199,7 @@ export class AttachmentService {
|
||||
} = opts;
|
||||
return this.attachmentRepo.insertAttachment(
|
||||
{
|
||||
id: attachmentId,
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
fileName: preparedFile.fileName,
|
||||
|
||||
Reference in New Issue
Block a user