Skip to content

Commit

Permalink
Fix markdown converter (Promise/await issues)
Browse files Browse the repository at this point in the history
  • Loading branch information
floscher committed Jan 10, 2023
1 parent 5fa7057 commit 90f24d7
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 68 deletions.
7 changes: 3 additions & 4 deletions client/src/components/MarkDown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@
<script lang="ts">
import type { PropType } from "vue";
import { defineComponent, ref, watch } from "vue";
import { sanitizeHtml } from "@fumix/fu-blog-common";
import DOMPurify from "dompurify";
import { MarkdownConverterClient } from "../markdown-converter-client.js";
export default defineComponent({
props: {
Expand All @@ -32,12 +31,12 @@ export default defineComponent({
emits: ["loading"],
setup(props, emits) {
const sanitizedHtml = ref<String>("");
const sanitizedHtml = ref<string>("");
watch(props, async () => {
try {
emits.emit("loading", true);
sanitizedHtml.value = sanitizeHtml(props.markdown as string, DOMPurify);
sanitizedHtml.value = await MarkdownConverterClient.Instance.convert(props.markdown ?? "");
} catch (e) {
// TODO erro handling
} finally {
Expand Down
26 changes: 16 additions & 10 deletions client/src/markdown-converter-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { marked } from "marked";
import {
createWalkTokensExtension,
rendererExtension,
} from "@fumix/fu-blog-common";

marked.use(
createWalkTokensExtension((url) => fetch(url).then((it) => it.text())),
);
marked.use(rendererExtension);
import { MarkdownConverter } from "@fumix/fu-blog-common";
import DOMPurify from "dompurify";

export class MarkdownConverterClient extends MarkdownConverter {
private static instance: MarkdownConverterClient;

protected override dompurify: DOMPurify.DOMPurifyI = DOMPurify;

private constructor() {
super((url: string) => fetch(url).then((it) => it.text()));
}

public static get Instance() {
return this.instance ?? (this.instance = new this());
}
}
92 changes: 55 additions & 37 deletions common/src/markdown-converter-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,64 @@ import { Buffer } from "buffer";
import pako from "pako";
import { DOMPurifyI } from "dompurify";

const KROKI_SERVICE_URL = "https://kroki.io";
const KROKI_DIAGRAM_INFOSTRING = "diagram-plantuml";

export function createWalkTokensExtension(fetchTextFromUrl: FetchTextFromUrlFunction): marked.MarkedExtension {
return {
async: true,
walkTokens: async (token: marked.Token) => {
if (token.type === "code" && token.lang === KROKI_DIAGRAM_INFOSTRING) {
const inputText = token.text;
const data = Buffer.from(inputText, "utf8");
const compressed = pako.deflate(data, { level: 9 });
const res = Buffer.from(compressed).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
token.text = await fetchTextFromUrl(`${KROKI_SERVICE_URL}/plantuml/svg/${res}`);
}
},
};
}

export const rendererExtension: marked.MarkedExtension = {
renderer: {
code(code: string, infostring: string) {
if (infostring === KROKI_DIAGRAM_INFOSTRING) {
return code;
}
return false;
},
},
};

export const sanitizeHtml: (input: string, purify: DOMPurifyI) => string = (input, purify) => {
return purify.sanitize(marked.parse(input), {
// Allowed tags and attributes inside markdown
ADD_TAGS: ["iframe"],
ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling"],
});
};

/**
* Takes a URL as argument, fetches text content from there and returns a promise resolving to that content.
* This is `(url) => fetch(url).then((it) => it.text())` on client and server, but using a different fetch() function
* (on the server from `node-fetch`, on the client the browser Fetch API is used).
*/
type FetchTextFromUrlFunction = (a: string) => Promise<string>;

export abstract class MarkdownConverter {
private static KROKI_SERVICE_URL = "https://kroki.io";
private static KROKI_DIAGRAM_INFOSTRING = "diagram-plantuml";

private static rendererExtension: marked.MarkedExtension = {
renderer: {
code(code: string, infostring: string) {
if (infostring === MarkdownConverter.KROKI_DIAGRAM_INFOSTRING) {
return code;
}
return false;
},
},
};
private static walkTokensExtension: (fetchTextFromUrl: FetchTextFromUrlFunction) => marked.MarkedExtension = //
(fetchTextFromUrl) => {
return {
async: true,
walkTokens: async (token: marked.Token) => {
if (token.type === "code" && token.lang === MarkdownConverter.KROKI_DIAGRAM_INFOSTRING) {
const inputText = token.text;
const data = Buffer.from(inputText, "utf8");
const compressed = pako.deflate(data, { level: 9 });
const res = Buffer.from(compressed).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
token.text = await fetchTextFromUrl(`${MarkdownConverter.KROKI_SERVICE_URL}/plantuml/svg/${res}`);
}
},
};
};

protected abstract dompurify: DOMPurifyI;

/**
*
* @param fetch the fetch function used by client/server
*/
protected constructor(fetch: FetchTextFromUrlFunction) {
// Initialize marked with our custom extensions
marked.use(MarkdownConverter.walkTokensExtension(fetch));
marked.use(MarkdownConverter.rendererExtension);
}

convert(input: string): Promise<string> {
return marked
.parse(input, { async: true }) //
.then((parsedInput) =>
this.dompurify.sanitize(parsedInput, {
// Allowed tags and attributes inside markdown
ADD_TAGS: ["iframe"],
ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling"],
}),
);
}
}
27 changes: 17 additions & 10 deletions server/src/markdown-converter-server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { marked } from "marked";
import { createWalkTokensExtension, rendererExtension } from "@fumix/fu-blog-common";
import { MarkdownConverter } from "@fumix/fu-blog-common";
import fetch from "node-fetch";
import DOMPurify from "dompurify";
import { JSDOM } from "jsdom";

marked.use(createWalkTokensExtension((url) => fetch(url).then((it) => it.text())));
marked.use(rendererExtension);
export class MarkdownConverterServer extends MarkdownConverter {
private static instance: MarkdownConverterServer;

/**
* On the server side, we need to pass a new window object to DOMPurify.
* It doesn't work to just call `DOMPurify.sanitize()` directly like on the client side.
*/
export const createDomPurify: () => DOMPurify.DOMPurifyI = //
() => DOMPurify(new JSDOM("").window as unknown as Window);
/**
* On the server side, we need to pass a new window object to DOMPurify.
* It doesn't work to just call `DOMPurify.sanitize()` directly like on the client side.
*/
protected override dompurify: DOMPurify.DOMPurifyI = DOMPurify(new JSDOM("").window as unknown as Window);

private constructor() {
super((url: string) => fetch(url).then((it) => it.text()));
}

public static get Instance() {
return this.instance ?? (this.instance = new this());
}
}
7 changes: 3 additions & 4 deletions server/src/routes/posts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { sanitizeHtml } from "@fumix/fu-blog-common";
import express, { Request, Response, Router } from "express";
import { AppDataSource } from "../data-source.js";
import { PostEntity } from "../entity/Post.entity.js";
import { UserEntity } from "../entity/User.entity.js";
import { createDomPurify } from "../markdown-converter-server.js";
import { MarkdownConverterServer } from "../markdown-converter-server.js";

const router: Router = express.Router();

Expand Down Expand Up @@ -92,7 +91,7 @@ router.post("/new", async (req: Request, res: Response) => {
createdBy: await getUser(),
createdAt: new Date(),
updatedAt: new Date(),
sanitizedHtml: sanitizeHtml(req.body.markdown, createDomPurify()),
sanitizedHtml: await MarkdownConverterServer.Instance.convert(req.body.markdown),
updatedBy: undefined,
draft: req.body.draft || true,
attachments: [],
Expand All @@ -118,7 +117,7 @@ router.post("/:id", async (req: Request, res: Response) => {
post.description = req.body.description;
post.markdown = req.body.markdown;
post.updatedAt = new Date();
post.sanitizedHtml = sanitizeHtml(req.body.markdown, createDomPurify());
post.sanitizedHtml = await MarkdownConverterServer.Instance.convert(req.body.markdown);
post.updatedBy = await getUser();
// TODO
post.draft = req.body.draft || true;
Expand Down
5 changes: 2 additions & 3 deletions server/src/service/testdata-generator.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { faker } from "@faker-js/faker/locale/de";
import { sanitizeHtml } from "@fumix/fu-blog-common";
import fs from "fs";
import { AppDataSource } from "../data-source.js";
import { AttachmentEntity } from "../entity/Attachment.entity.js";
import { PostEntity } from "../entity/Post.entity.js";
import { UserEntity } from "../entity/User.entity.js";
import { createDomPurify } from "../markdown-converter-server.js";
import { MarkdownConverterServer } from "../markdown-converter-server.js";

const usersCount = 10;
const postsPerUser = 25;
Expand Down Expand Up @@ -64,7 +63,7 @@ export async function createRandomUser(): Promise<UserEntity> {
export async function createRandomPost(createdBy: UserEntity): Promise<PostEntity> {
try {
const dirty = faker.lorem.sentences(29);
const sanitized = sanitizeHtml(dirty, createDomPurify());
const sanitized = await MarkdownConverterServer.Instance.convert(dirty);
const post: PostEntity = {
title: faker.lorem.sentence(4),
description: faker.lorem.sentences(8),
Expand Down

0 comments on commit 90f24d7

Please sign in to comment.