Skip to content

Commit

Permalink
create tooltip contents in a xss-safe way
Browse files Browse the repository at this point in the history
  • Loading branch information
yagebu committed Jul 30, 2022
1 parent 6aba5e7 commit 68bbb6e
Show file tree
Hide file tree
Showing 8 changed files with 73 additions and 43 deletions.
4 changes: 2 additions & 2 deletions frontend/src/charts/ScatterPlot.svelte
Expand Up @@ -10,7 +10,7 @@
import { scatterplotScale } from "./helpers";
import type { ScatterPlotDatum } from "./scatterplot";
import type { TooltipFindNode } from "./tooltip";
import { positionedTooltip } from "./tooltip";
import { domHelpers, positionedTooltip } from "./tooltip";
export let data: ScatterPlotDatum[];
export let width: number;
Expand Down Expand Up @@ -51,7 +51,7 @@
);
function tooltipText(d: ScatterPlotDatum) {
return `${d.description}<em>${day(d.date)}</em>`;
return [domHelpers.t(d.description), domHelpers.em(day(d.date))];
}
const tooltipFindNode: TooltipFindNode = (xPos, yPos) => {
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/charts/Treemap.svelte
Expand Up @@ -11,7 +11,7 @@
AccountHierarchyDatum,
AccountHierarchyNode,
} from "./hierarchy";
import { followingTooltip } from "./tooltip";
import { domHelpers, followingTooltip } from "./tooltip";
export let data: AccountHierarchyNode;
export let width: number;
Expand All @@ -35,9 +35,12 @@
const val = d.value ?? 0;
const rootValue = root.value || 1;
return `${$ctx.amount(val, currency)} (${formatPercentage(
val / rootValue
)})<em>${d.data.account}</em>`;
return [
domHelpers.t(
`${$ctx.amount(val, currency)} (${formatPercentage(val / rootValue)})`
),
domHelpers.em(d.data.account),
];
}
function setVisibility(
Expand Down
35 changes: 24 additions & 11 deletions frontend/src/charts/bar.ts
Expand Up @@ -7,6 +7,8 @@ import type { Result } from "../lib/result";
import { array, date, number, object, record } from "../lib/validation";

import type { ChartContext } from "./context";
import type { TooltipContent } from "./tooltip";
import { domHelpers } from "./tooltip";

export interface BarChartDatumValue {
currency: string;
Expand Down Expand Up @@ -38,7 +40,11 @@ export interface BarChart {
/** Whether this chart contains any stacks (or is just a single account). */
hasStackedData: boolean;
};
tooltipText: (c: FormatterContext, d: BarChartDatum, e: string) => string;
tooltipText: (
c: FormatterContext,
d: BarChartDatum,
e: string
) => TooltipContent;
}

const bar_validator = array(
Expand Down Expand Up @@ -91,24 +97,31 @@ export function bar(
type: "barchart" as const,
data: { accounts, bar_groups, stacks, hasStackedData },
tooltipText: (c, d, e) => {
let text = "";
const content: TooltipContent = [];
if (e === "") {
d.values.forEach((a) => {
text += c.amount(a.value, a.currency);
if (a.budget) {
text += ` / ${c.amount(a.budget, a.currency)}`;
}
text += "<br>";
content.push(
domHelpers.t(
a.budget
? `${c.amount(a.value, a.currency)} / ${c.amount(
a.budget,
a.currency
)}`
: c.amount(a.value, a.currency)
)
);
content.push(domHelpers.br());
});
} else {
text += `<em>${e}</em>`;
content.push(domHelpers.em(e));
d.values.forEach((a) => {
const value = d.account_balances[e]?.[a.currency] ?? 0;
text += `${c.amount(value, a.currency)}<br>`;
content.push(domHelpers.t(`${c.amount(value, a.currency)}`));
content.push(domHelpers.br());
});
}
text += `<em>${d.label}</em>`;
return text;
content.push(domHelpers.em(d.label));
return content;
},
});
}
19 changes: 7 additions & 12 deletions frontend/src/charts/context.ts
Expand Up @@ -2,7 +2,7 @@ import type { Readable } from "svelte/store";
import { derived } from "svelte/store";

import { currentDateFormat } from "../format";
import { conversion, operating_currency } from "../stores";
import { conversion, currencies, operating_currency } from "../stores";

export type ChartContext = {
currencies: string[];
Expand All @@ -13,17 +13,12 @@ export type ChartContext = {
* The list of operating currencies, adding in the current conversion currency.
*/
const operatingCurrenciesWithConversion = derived(
[operating_currency, conversion],
([operating_currency_val, conversion_val]) => {
if (
!conversion_val ||
["at_cost", "at_value", "units"].includes(conversion_val) ||
operating_currency_val.includes(conversion_val)
) {
return operating_currency_val;
}
return [...operating_currency_val, conversion_val];
}
[operating_currency, currencies, conversion],
([operating_currency_val, currencies_val, conversion_val]) =>
currencies_val.includes(conversion_val) &&
!operating_currency_val.includes(conversion_val)
? [...operating_currency_val, conversion_val]
: operating_currency_val
);

export const chartContext: Readable<ChartContext> = derived(
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/charts/line.ts
Expand Up @@ -12,6 +12,9 @@ import {
tuple,
} from "../lib/validation";

import type { TooltipContent } from "./tooltip";
import { domHelpers } from "./tooltip";

export interface LineChartDatum {
name: string;
date: Date;
Expand All @@ -26,7 +29,7 @@ export type LineChartData = {
export interface LineChart {
type: "linechart";
data: LineChartData[];
tooltipText: (c: FormatterContext, d: LineChartDatum) => string;
tooltipText: (c: FormatterContext, d: LineChartDatum) => TooltipContent;
}

const balances_validator = array(object({ date, balance: record(number) }));
Expand Down Expand Up @@ -57,8 +60,10 @@ export function balances(json: unknown): Result<LineChart, string> {
return ok({
type: "linechart" as const,
data,
tooltipText: (c, d) =>
`${c.amount(d.value, d.name)}<em>${day(d.date)}</em>`,
tooltipText: (c, d) => [
domHelpers.t(c.amount(d.value, d.name)),
domHelpers.em(day(d.date)),
],
});
}

Expand All @@ -82,8 +87,9 @@ export function commodities(
return ok({
type: "linechart" as const,
data: [{ name: label, values }],
tooltipText(c, d) {
return `1 ${base} = ${c.amount(d.value, quote)}<em>${day(d.date)}</em>`;
},
tooltipText: (c, d) => [
domHelpers.t(`1 ${base} = ${c.amount(d.value, quote)}`),
domHelpers.em(day(d.date)),
],
});
}
25 changes: 19 additions & 6 deletions frontend/src/charts/tooltip.ts
Expand Up @@ -19,6 +19,19 @@ const hide = (): void => {
t.style.opacity = "0";
};

/** Some small utilities to create tooltip contents. */
export const domHelpers = {
br: () => document.createElement("br"),
em: (content: string) => {
const em = document.createElement("em");
em.textContent = content;
return em;
},
t: (text: string) => document.createTextNode(text),
};

export type TooltipContent = (HTMLElement | Text)[];

/**
* Svelte action to have the given element act on mouse to show a tooltip.
*
Expand All @@ -27,8 +40,8 @@ const hide = (): void => {
*/
export function followingTooltip(
node: SVGElement,
text: () => string
): { destroy: () => void; update: (t: () => string) => void } {
text: () => TooltipContent
): { destroy: () => void; update: (t: () => TooltipContent) => void } {
let getter = text;
/** Event listener to have the tooltip follow the mouse. */
function followMouse(event: MouseEvent): void {
Expand All @@ -39,14 +52,14 @@ export function followingTooltip(
}
node.addEventListener("mouseenter", () => {
const t = tooltip();
t.innerHTML = getter();
t.replaceChildren(...getter());
});
node.addEventListener("mousemove", followMouse);
node.addEventListener("mouseleave", hide);

return {
destroy: hide,
update(t: () => string): void {
update(t: () => TooltipContent): void {
getter = t;
},
};
Expand All @@ -56,7 +69,7 @@ export function followingTooltip(
export type TooltipFindNode = (
x: number,
y: number
) => [number, number, string] | undefined;
) => [number, number, TooltipContent] | undefined;

/**
* Svelte action to have the given <g> element act on mouse to show a tooltip.
Expand All @@ -78,7 +91,7 @@ export function positionedTooltip(
const [x, y, content] = res;
const t = tooltip();
t.style.opacity = "1";
t.innerHTML = content;
t.replaceChildren(...content);
t.style.left = `${window.scrollX + x + matrix.e}px`;
t.style.top = `${window.scrollY + y + matrix.f - 15}px`;
} else {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/keyboard-shortcuts.ts
Expand Up @@ -10,7 +10,7 @@ function showTooltip(target: HTMLElement): () => void {
target.classList.remove("hidden");
}
tooltip.className = "keyboard-tooltip";
tooltip.innerHTML = target.getAttribute("data-key") || "";
tooltip.textContent = target.getAttribute("data-key") ?? "";
document.body.appendChild(tooltip);
const parentCoords = target.getBoundingClientRect();
// Padded 10px to the left if there is space or centered otherwise
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/sidebar/index.ts
Expand Up @@ -26,7 +26,7 @@ export function initSidebar(): void {
errorCountEl.classList.toggle("hidden", errorCount_val === 0);
const span = errorCountEl.querySelector("span");
if (span) {
span.innerHTML = `${errorCount_val}`;
span.textContent = `${errorCount_val}`;
}
});
}
Expand Down

0 comments on commit 68bbb6e

Please sign in to comment.