editor improvements

* add callout, youtube embed, image, video, table, detail, math
* fix attachments module
* other fixes
This commit is contained in:
Philipinho
2024-06-20 14:57:00 +01:00
parent c7925739cb
commit 1f4bd129a8
74 changed files with 5205 additions and 381 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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;
},
);

View File

@ -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;
},
);

View File

@ -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();

View File

@ -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';

View File

@ -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) {

View File

@ -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();

View File

@ -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,