diff --git a/server/editor/index.ts b/server/editor/index.ts index 3fc38d1357..9371d34053 100644 --- a/server/editor/index.ts +++ b/server/editor/index.ts @@ -1,6 +1,10 @@ import { Schema } from "prosemirror-model"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; -import { richExtensions, withComments } from "@shared/editor/nodes"; +import { + basicExtensions, + richExtensions, + withComments, +} from "@shared/editor/nodes"; const extensions = withComments(richExtensions); export const extensionManager = new ExtensionManager(extensions); @@ -29,3 +33,26 @@ export const parser = extensionManager.parser({ export const serializer = extensionManager.serializer(); export const plugins = extensionManager.plugins; + +export const basicExtensionManager = new ExtensionManager(basicExtensions); + +export const basicSchema = new Schema({ + nodes: basicExtensionManager.nodes, + marks: basicExtensionManager.marks, +}); + +for (const extension of basicExtensionManager.extensions) { + extension.bindEditor({ + schema: basicSchema, + props: { + theme: { + isDark: false, + }, + }, + } as any); +} + +export const basicParser = basicExtensionManager.parser({ + schema: basicSchema, + plugins: basicExtensionManager.rulePlugins, +}); diff --git a/server/models/Comment.ts b/server/models/Comment.ts index 31463b1991..a85be23a32 100644 --- a/server/models/Comment.ts +++ b/server/models/Comment.ts @@ -13,7 +13,7 @@ import { import type { ProsemirrorData, ReactionSummary } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { CommentValidation } from "@shared/validations"; -import { schema } from "@server/editor"; +import { basicSchema } from "@server/editor"; import { ValidationError } from "@server/errors"; import Document from "./Document"; import User from "./User"; @@ -137,7 +137,7 @@ class Comment extends ParanoidModel< * @returns The plain text representation of the comment data */ public toPlainText() { - const node = Node.fromJSON(schema, this.data); + const node = Node.fromJSON(basicSchema, this.data); return ProsemirrorHelper.toPlainText(node); } diff --git a/server/routes/api/comments/__snapshots__/comments.test.ts.snap b/server/routes/api/comments/__snapshots__/comments.test.ts.snap index a4f59be1b5..6e82c26bc3 100644 --- a/server/routes/api/comments/__snapshots__/comments.test.ts.snap +++ b/server/routes/api/comments/__snapshots__/comments.test.ts.snap @@ -9,59 +9,6 @@ exports[`#comments.add_reaction should require authentication 1`] = ` } `; -exports[`#comments.create should create a comment from markdown text 1`] = ` -{ - "content": [ - { - "attrs": { - "level": 2, - }, - "content": [ - { - "text": "heading", - "type": "text", - }, - ], - "type": "heading", - }, - { - "content": [ - { - "content": [ - { - "content": [ - { - "text": "list item 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "list_item", - }, - { - "content": [ - { - "content": [ - { - "text": "list item 2", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "list_item", - }, - ], - "type": "bullet_list", - }, - ], - "type": "doc", -} -`; - exports[`#comments.create should require authentication 1`] = ` { "error": "authentication_required", diff --git a/server/routes/api/comments/comments.test.ts b/server/routes/api/comments/comments.test.ts index 4e2a2bae6e..40d5724bb1 100644 --- a/server/routes/api/comments/comments.test.ts +++ b/server/routes/api/comments/comments.test.ts @@ -487,7 +487,7 @@ describe("#comments.create", () => { teamId: user.teamId, }); - const text = "## heading\n\n- list item 1\n- list item 2"; + const text = "test\n\n- list item 1\n- list item 2"; const res = await server.post("/api/comments.create", { body: { @@ -500,7 +500,9 @@ describe("#comments.create", () => { const body = await res.json(); expect(res.status).toEqual(200); - expect(body.data.data).toMatchSnapshot(); + expect(body.data.data.content[0].type).toEqual("paragraph"); + expect(body.data.data.content[0].content[0].text).toEqual("test"); + expect(body.data.data.content[1].type).toEqual("bullet_list"); }); it("should not allow empty comment data", async () => { @@ -651,6 +653,71 @@ describe("#comments.create", () => { expect(res.status).toEqual(400); }); + + it("should not allow rich formatting nodes", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Heading" }], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(400); + }); + + it("should allow list nodes", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/comments.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + data: { + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item 1" }], + }, + ], + }, + ], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(200); + }); }); describe("#comments.update", () => { @@ -690,6 +757,38 @@ describe("#comments.update", () => { expect(body.policies[0].abilities.update).toBeTruthy(); expect(body.policies[0].abilities.delete).toBeTruthy(); }); + + it("should not allow rich formatting nodes in update", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const res = await server.post("/api/comments.update", { + body: { + token: user.getJwtToken(), + id: comment.id, + data: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Heading" }], + }, + ], + }, + }, + }); + + expect(res.status).toEqual(400); + }); }); describe("#comments.resolve", () => { diff --git a/server/routes/api/comments/comments.ts b/server/routes/api/comments/comments.ts index 08871bd3ab..901028c3df 100644 --- a/server/routes/api/comments/comments.ts +++ b/server/routes/api/comments/comments.ts @@ -9,7 +9,7 @@ import { IconType, } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; -import { parser } from "@server/editor"; +import { basicParser } from "@server/editor"; import auth from "@server/middlewares/authentication"; import { feature } from "@server/middlewares/feature"; import { rateLimiter } from "@server/middlewares/rateLimiter"; @@ -52,7 +52,7 @@ router.post( user ) : undefined; - const data = text ? parser.parse(text).toJSON() : ctx.input.body.data; + const data = text ? basicParser.parse(text).toJSON() : ctx.input.body.data; const comment = await Comment.createWithCtx(ctx, { id, diff --git a/server/routes/api/comments/schema.ts b/server/routes/api/comments/schema.ts index f59720f058..f0df9e181b 100644 --- a/server/routes/api/comments/schema.ts +++ b/server/routes/api/comments/schema.ts @@ -1,6 +1,7 @@ import isEmpty from "lodash/isEmpty"; import { z } from "zod"; import { CommentStatusFilter } from "@shared/types"; +import { basicSchema } from "@server/editor"; import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema"; import { zodEmojiType } from "@server/utils/zod"; @@ -36,7 +37,7 @@ export const CommentsCreateSchema = BaseSchema.extend({ parentCommentId: z.string().uuid().optional(), /** Create comment with this data */ - data: ProsemirrorSchema().optional(), + data: ProsemirrorSchema({ schema: basicSchema }).optional(), /** Create comment with this text */ text: z.string().optional(), @@ -51,7 +52,7 @@ export type CommentsCreateReq = z.infer; export const CommentsUpdateSchema = BaseSchema.extend({ body: BaseIdSchema.extend({ /** Update comment with this data */ - data: ProsemirrorSchema(), + data: ProsemirrorSchema({ schema: basicSchema }), }), }); diff --git a/server/routes/api/schema.ts b/server/routes/api/schema.ts index 2782f714fd..b52a8e237d 100644 --- a/server/routes/api/schema.ts +++ b/server/routes/api/schema.ts @@ -1,4 +1,5 @@ import type formidable from "formidable"; +import type { Schema } from "prosemirror-model"; import { Node } from "prosemirror-model"; import { z } from "zod"; import type { ProsemirrorData as TProsemirrorData } from "@shared/types"; @@ -16,15 +17,18 @@ export const BaseSchema = z.object({ * * @param allowEmpty - Whether to allow an empty document. */ -export const ProsemirrorSchema = (options?: { allowEmpty: boolean }) => - z.custom((val) => { +export const ProsemirrorSchema = (options?: { + allowEmpty?: boolean; + schema?: Schema; +}) => { + const s = options?.schema ?? schema; + return z.custom((val) => { try { - const node = Node.fromJSON(schema, val); + const node = Node.fromJSON(s, val); node.check(); - return options?.allowEmpty - ? true - : !ProsemirrorHelper.isEmpty(node, schema); + return options?.allowEmpty ? true : !ProsemirrorHelper.isEmpty(node, s); } catch (_e) { return false; } }, "Invalid data"); +}; diff --git a/shared/editor/nodes/SimpleImage.tsx b/shared/editor/nodes/SimpleImage.tsx index 84d84889f3..d82e08dee7 100644 --- a/shared/editor/nodes/SimpleImage.tsx +++ b/shared/editor/nodes/SimpleImage.tsx @@ -82,6 +82,8 @@ export default class SimpleImage extends Node { }, ], ], + leafText: (node) => + node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)", }; } diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 9b7b6574ba..d9538d5374 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -47,10 +47,10 @@ import Video from "./Video"; type Nodes = (typeof Node | typeof Mark | typeof Extension)[]; /** - * The basic set of nodes that are used in the editor. This is used for simple + * A set of inline nodes that are used in the editor. This is used for simple * editors that need basic formatting. */ -export const basicExtensions: Nodes = [ +export const inlineExtensions: Nodes = [ Doc, Paragraph, Emoji, @@ -87,12 +87,18 @@ export const tableExtensions: Nodes = [ Table, ]; +/** + * The basic set of nodes that are used in the editor. This is used for simple + * editors that need basic formatting and lists. + */ +export const basicExtensions: Nodes = [...inlineExtensions, ...listExtensions]; + /** * The full set of nodes that are used in the editor. This is used for rich * editors that need advanced formatting. */ export const richExtensions: Nodes = [ - ...basicExtensions.filter((n) => n !== SimpleImage), + ...inlineExtensions.filter((n) => n !== SimpleImage), Image, CodeBlock, CodeFence,