Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 4 additions & 75 deletions components/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { marked } from "marked";
import { sanitize } from "~/src/domain/services/sanitizer";
import { onMounted, ref, useRestApi, watch } from "#imports";

const props = defineProps({
Expand All @@ -15,8 +16,6 @@ const props = defineProps({
}
});

const rest = useRestApi().value;

const sanitizedDescription = ref("");

const options = {
Expand All @@ -34,83 +33,13 @@ onMounted(() => {
sanitizeDescription();
});

function markdown (src: string) {
function convert_markdown_to_html (src: string) {
return marked(src, options);
}

async function sanitizeDescription () {
// Get the original not sanitized markdown string.
const description = markdown(props.source);

// Replace the img src's with a random id and return a map
// of these ids mapped to the original url.
const [filteredDescriptionWithImageIds, imageIdUrlMap] = filterDescriptionImagesWithRandomIds(description);

// Get the image data using the backend's image proxy.
const imageIdDataUrlMap = await getImageDataUrlsFromUrls(imageIdUrlMap);

// Replace the img id's with the proxied sources.
sanitizedDescription.value = replaceDescriptionImageIdsWithDataUrls(filteredDescriptionWithImageIds, imageIdDataUrlMap);
}

function filterDescriptionImagesWithRandomIds (description: string): [string, Map<string, string>] {
const filteredImageMap = new Map();

// Replace all image urls with a random id.
description = description.replace(/img src="(.*?)"/gi, (match, url): string => {
const imageId = randomId(32);

filteredImageMap.set(imageId, url);

return `img src="${imageId}"`;
});

return [description, filteredImageMap];
}

async function getImageDataUrlsFromUrls (imageMap: Map<string, string>): Promise<Map<string, string>> {
const imageDataMap: Map<string, string> = new Map();

for (const [id, url] of imageMap) {
const imageBlob = await rest.torrent.proxiedImage(url);
const imageDataUrl = await blobToDataURL(imageBlob);

imageDataMap.set(id, imageDataUrl);
}

return imageDataMap;
}

function blobToDataURL (blob: Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = _e => resolve(reader.result as string);
reader.onerror = _e => reject(reader.error);
reader.onabort = _e => reject(new Error("Read aborted"));
reader.readAsDataURL(blob);
});
}

function replaceDescriptionImageIdsWithDataUrls (description: string, imageIdDataUrlMap: Map<string, string>): string {
imageIdDataUrlMap.forEach((dataUrl, id) => {
description = description.replace(id, dataUrl);
});

return description;
}

function randomId (length: number) {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let counter = 0;

while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}

return result;
const html = convert_markdown_to_html(props.source);
sanitizedDescription.value = await sanitize(html);
}
</script>

Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@nuxtjs/tailwindcss": "^6.8.0",
"@tailwindcss/typography": "^0.5.9",
"@types/dompurify": "^3.0.2",
"@types/marked": "^5.0.0",
"@types/node": "^20.3.2",
"@typescript-eslint/eslint-plugin": "^5.60.0",
Expand All @@ -32,6 +33,7 @@
"dependencies": {
"@heroicons/vue": "^2.0.18",
"daisyui": "^3.1.7",
"dompurify": "^3.0.4",
"marked": "^5.1.0",
"notiwind-ts": "^2.0.2",
"torrust-index-api-lib": "^0.2.0",
Expand Down
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
composables
dompurify
heroicons
notiwind
Nuxt
Expand Down
97 changes: 97 additions & 0 deletions src/domain/services/sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import DOMPurify from "dompurify";
import { useRestApi } from "#imports";

const rest = useRestApi().value;

const allowedTags = ["h1", "h2", "h3", "h4", "h5", "h6", "em", "strong", "del", "a", "img", "ul", "ol", "li", "hr"];
const allowedImageExtensions = ["png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "gif", "GIF"];

export async function sanitize (html: string) {
const safeHtml = remove_harmful_code(html);
const htmlWithNoUserTracking = await remove_user_tracking(safeHtml);
return htmlWithNoUserTracking;
}

function remove_harmful_code (html: string) {
return DOMPurify.sanitize(html, { ALLOWED_TAGS: allowedTags });
}

async function remove_user_tracking (html: string) {
// Parse the description as HTML to easily manipulate it.
const parser = new DOMParser();

const htmlDoc = parser.parseFromString(html, "text/html");

remove_all_external_links(htmlDoc);
await replace_images_with_proxied_images(htmlDoc);

return document_to_html(htmlDoc);
}

function remove_all_external_links (htmlDoc: Document) {
const links = htmlDoc.querySelectorAll("a");
links.forEach((link) => {
const href = link.getAttribute("href");
if (href && !href.startsWith("#")) {
link.removeAttribute("href");
}
});
}

async function replace_images_with_proxied_images (htmlDoc: Document) {
const images = htmlDoc.querySelectorAll("img");
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute("src");

if (src) {
if (isAllowedImage(src)) {
const imageDataSrc = await getImageDataUrl(src);
img.setAttribute("src", imageDataSrc);
} else {
img.remove();
}
}
}
}

function document_to_html (descriptionHtml: Document) {
const body = descriptionHtml.querySelector("body");
const serializer = new XMLSerializer();
let html = "";
if (body) {
html = serializer.serializeToString(body);
html = html
.replace("<body xmlns=\"http://www.w3.org/1999/xhtml\">", "")
.replace("<body>", "")
.replace("</body>", "");
}
return html;
}

// Returns true if the image is allowed to be displayed.
function isAllowedImage (href: string): boolean {
const extension = href.split(".").pop().trim();
return allowedImageExtensions.includes(extension);
}

// Returns a base64 string ready to be use in a "src" attribute in a "img" html tag,
// like this `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gA…IiIiIiIiIiIiIiHyO/P85XT/jxW1glg5Erk==">`.
async function getImageDataUrl (url: string): Promise<string> {
const imageBlob = await rest.torrent.proxiedImage(url);
const data = await blobToDataURL(imageBlob);
return data;
}

// Convert binary data into a base64 encoded string ready to be use in a "src"
// attribute in a "img" html tag, like the following:
// `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gA…IiIiIiIiIiIiIiHyO/P85XT/jxW1glg5Erk==">`.
function blobToDataURL (blob: Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = _e => resolve(reader.result as string);
reader.onerror = _e => reject(reader.error);
reader.onabort = _e => reject(new Error("Read aborted"));
reader.readAsDataURL(blob);
});
}