Skip to content

Commit

Permalink
Eliminate use of v-html when rendering node names and descriptions (#908
Browse files Browse the repository at this point in the history
)

### Related issues

- Closes #902

### Summary

- Adds a new component, `<AppNodeText>` which safely renders text
containing `<i>`, `<sup>`, and `<a>` tags, while also leaving arbitrary
text \<enclosed in brackets\> untouched.
- Removes the usage of the unsafe `v-html` directive when rendering
nodes, replacing it with `<AppNodeText>`

### Checks

- [x] All tests have passed (or issues created for failing tests)

---------

Co-authored-by: Patrick Golden <[email protected]>
  • Loading branch information
ptgolden and Patrick Golden authored Nov 21, 2024
1 parent 16a4db4 commit af5ab7b
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 11 deletions.
10 changes: 7 additions & 3 deletions frontend/src/components/AppNodeBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
? { breadcrumbs: [...currentBreadcrumbs, ...breadcrumbs] }
: state || undefined
"
v-html="name"
></AppLink>
>
<AppNodeText :text="name" />
</AppLink>
<span v-else>
<span class="name" v-html="name"></span>
<span class="name">
<AppNodeText :text="name" />
</span>
<span v-if="info">({{ info }})</span>
</span>
</span>
Expand All @@ -31,6 +34,7 @@
import { computed } from "vue";
import { getCategoryIcon, getCategoryLabel } from "@/api/categories";
import type { Node } from "@/api/model";
import AppNodeText from "@/components/AppNodeText.vue";
import { breadcrumbs as currentBreadcrumbs } from "@/global/breadcrumbs";
import type { Breadcrumb } from "@/global/breadcrumbs";

Expand Down
228 changes: 228 additions & 0 deletions frontend/src/components/AppNodeText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<!--
The text of a node in the knowledge graph.

Selectively renders the following tags in HTML and SVG:
- <sup>
- <i>
- <a> with an `href` attribute surrounded in double quotes

There are two alternatives to the approach taken here, but neither are
sufficient.

1. We could use a sanitizer like [DOMPurify](https://github.com/cure53/DOMPurify)
to sanitize arbitrary strings, but that would strip out legitimate text
that an HTML parser might confuse for a tag. An example of such text can be
found here: <https://github.com/monarch-initiative/monarch-app/issues/887#issuecomment-2479676335>

2. We could escape the entire string, selectively unescape `&lt;sup&gt;` (and
so on), and then pass the string to `containerEl.innerHTML`. However, this
would lead to markup without the desired effect in SVG, since the <sup> and
<i> elements do not do anything in SVG.

-->

<template>
<tspan v-if="isSvg" ref="container">
{{ text }}
</tspan>
<span v-else ref="container">
{{ text }}
</span>
</template>

<script setup lang="ts">
import { onMounted, onUpdated, ref } from "vue";

type Props = {
text?: string;
isSvg?: boolean;
};

const props = withDefaults(defineProps<Props>(), {
text: "",
isSvg: false,
});

const container = ref<HTMLSpanElement | SVGTSpanElement | null>(null);

type ReplacedTag = "sup" | "a" | "i";

type Replacement = {
type: ReplacedTag;
start: [number, number];
end: [number, number];
startNode?: Text;
endNode?: Text;
};

type ReplacementPosition = {
type: "start" | "end";
replacement: Replacement;
at: [number, number];
};

const replacementTags = new Map([
[
"sup" as ReplacedTag,
{
regex: /(<sup>).*?(<\/sup>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("sup");
},
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
el.setAttribute("dy", "-1ex");
el.classList.add("svg-superscript");

// The next sibling will be the text node "</sup>". Check if there is
// remaining text after that. If there is, adjust the text baseline back
// down to the normal level.
const nextSibling = el.nextSibling!.nextSibling;
if (!nextSibling) return;

const range = new Range();
range.selectNode(nextSibling);

const tspan = document.createElementNS(
"http://www.w3.org/2000/svg",
"tspan",
);

tspan.setAttribute("dy", "+1ex");

range.surroundContents(tspan);
},
},
],
[
"i" as ReplacedTag,
{
regex: /(<i>).*?(<\/i>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("i");
},
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
el.classList.add("svg-italic");
},
},
],
[
"a" as ReplacedTag,
{
regex: /(<a href="http[^"]+">).*?(<\/a>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "a")
: document.createElement("a");
},
afterMount(isSvg: Boolean, el: Element) {
// The previous sibling will be the text node containing the string
// <a href="http...">. Slice it to get the value of the href.
const tagTextNode = el.previousSibling!;
const href = tagTextNode.textContent!.slice(9, -2);
el.setAttribute("href", href);
},
},
],
]);

function buildDOM(containerEl: Element) {
const text = props.text;

const containsOnlyText =
containerEl.childNodes.length === 1 &&
containerEl.firstChild?.nodeType === Node.TEXT_NODE &&
text !== null;

// This should always be false, but just in case-- bail out of the function
// if the element contains anything but a single text node.
if (!containsOnlyText) return;

const textNode = containerEl.firstChild as Text;

const replacements: Replacement[] = [];

// Create a list of every place there's a match for a start and end tag
// matched from the defined regexes.
Array.from(replacementTags.entries()).forEach(([type, { regex }]) => {
for (const match of text.matchAll(regex)) {
const { indices } = match;

replacements.push({
type,
start: indices![1],
end: indices![2],
});
}
});

// Now create a new list that has the position of each start and end token
const positions: ReplacementPosition[] = replacements.flatMap((x) => [
{ type: "start", replacement: x, at: x.start },
{ type: "end", replacement: x, at: x.end },
]);

// Sort that list by the position of the tag token (with the last token
// first and the first token last).
//
// After that, iterate through each of the token positions and split the
// text node at the token's boundaries. Store the text node of each start
// and end tag in the `replacements` array to be used later.
positions
.sort((a, b) => {
return b.at[0] - a.at[0];
})
.forEach((position) => {
textNode.splitText(position.at[1]);
const node = textNode.splitText(position.at[0]);
position.replacement[`${position.type}Node`] = node;
});

// Build the correct DOM tree for each replacement found
replacements.forEach((replacement) => {
const { startNode, endNode, type } = replacement;
const { createSurroundingEl, afterMount } = replacementTags.get(type)!;

// Select the range that goes from the end of the opening tag text node to
// the start of the closing tag text node.
const range = new Range();
range.setStartAfter(startNode!);
range.setEndBefore(endNode!);

// Surround that range with the appropriate DOM element.
const el = createSurroundingEl(props.isSvg);
range.surroundContents(el);

// Run any code required after the container element is mounted.
afterMount(props.isSvg, el);

// Remove the start and end tag text nodes
startNode!.parentNode!.removeChild(startNode!);
endNode!.parentNode!.removeChild(endNode!);
});
}

onMounted(() => {
if (!container.value) return;
buildDOM(container.value);
});

onUpdated(() => {
if (!container.value) return;
buildDOM(container.value);
});
</script>

<style>
.svg-superscript {
font-size: 0.7rem;
}
.svg-italic {
font-style: italic;
}
</style>
14 changes: 7 additions & 7 deletions frontend/src/pages/node/SectionOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
v-tooltip="'Click to expand'"
class="description truncate-10"
tabindex="0"
v-html="node.description?.trim()"
></p>
>
<AppNodeText :text="node.description?.trim()" />
</p>
</AppDetail>

<!-- inheritance -->
Expand Down Expand Up @@ -71,11 +72,9 @@
title="Also Known As"
:full="true"
>
<p
class="truncate-2"
tabindex="0"
v-html="node.synonym?.join(',\n&nbsp;')"
></p>
<p class="truncate-2" tabindex="0">
<AppNodeText :text="node.synonym?.join(',\n&nbsp;')" />
</p>
</AppDetail>

<!-- URI -->
Expand Down Expand Up @@ -155,6 +154,7 @@ import type { Node } from "@/api/model";
import AppDetail from "@/components/AppDetail.vue";
import AppDetails from "@/components/AppDetails.vue";
import AppNodeBadge from "@/components/AppNodeBadge.vue";
import AppNodeText from "@/components/AppNodeText.vue";
import { scrollTo } from "@/router";
import { sleep } from "@/util/debug";

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/node/SectionTitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
>
<span
:style="{ textDecoration: node.deprecated ? 'line-through' : '' }"
v-html="node.name"
>
<AppNodeText :text="node.name" />
</span>
<template v-if="node.deprecated"> (OBSOLETE)</template>
</AppHeading>
Expand All @@ -48,6 +48,7 @@ import { computed } from "vue";
import { truncate } from "lodash";
import { getCategoryIcon, getCategoryLabel } from "@/api/categories";
import type { Node } from "@/api/model";
import AppNodeText from "@/components/AppNodeText.vue";
import { parse } from "@/util/object";

type Props = {
Expand Down

0 comments on commit af5ab7b

Please sign in to comment.