Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/markdown/go-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ markdown.NewLine("my super new line text")

Add a new line to the markdown text.

### RenderHTML

```golang
import "github.com/perses/plugins/markdown/sdk/go"

markdown.RenderHTML(true)
```

Render raw HTML tags found in the markdown text. Disabled by default, in which case raw HTML is displayed as plain text.

## Example

```golang
Expand Down
8 changes: 8 additions & 0 deletions docs/markdown/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@
kind: "Markdown"
spec:
text: <string>
renderHtml: <boolean> # Optional
```

## Spec fields

- `text`: the Markdown text displayed in the panel. Dashboard variables are replaced before rendering.
- `renderHtml`: when `true`, raw HTML tags in `text` are rendered (after sanitization, which strips scripts and other
unsafe content). Defaults to `false`: raw HTML is then escaped and displayed as plain text, so placeholders such as
`<host>:<port>` are kept.
3 changes: 2 additions & 1 deletion markdown/schemas/markdown.cue
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ package model

kind: "Markdown"
spec: close({
text: string
text: string
renderHtml?: bool
})
6 changes: 6 additions & 0 deletions markdown/schemas/migrate/migrate.cue
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ kind: "Markdown"
if #panel.mode != _|_ {
spec: {
text: #panel.content
if #panel.mode == "html" {
renderHtml: true
}
}
}
if #panel.options != _|_ {
spec: {
text: #panel.options.content
if #panel.options.mode != _|_ if #panel.options.mode == "html" {
renderHtml: true
}
}
}
if #panel.options == _|_ && #panel.mode == _|_ {
Expand Down
6 changes: 6 additions & 0 deletions markdown/schemas/migrate/tests/text-markdown/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"kind": "Markdown",
"spec": {
"text": "# Title\n\nSome **markdown** content"
}
}
15 changes: 15 additions & 0 deletions markdown/schemas/migrate/tests/text-markdown/input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 1
},
"id": 4,
"options": {
"content": "# Title\n\nSome **markdown** content",
"mode": "markdown"
},
"title": "Markdown text",
"type": "text"
}
3 changes: 2 additions & 1 deletion markdown/schemas/migrate/tests/text/expected.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"kind": "Markdown",
"spec": {
"text": "This dashboard does have some purpose, let me remind... uuuuh..."
"text": "This dashboard does have some purpose, let me remind... uuuuh...",
"renderHtml": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "Markdown",
"spec": {
"text": "Some text",
"renderHtml": "yes"
}
}
7 changes: 7 additions & 0 deletions markdown/schemas/tests/valid/markdown-with-html.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "Markdown",
"spec": {
"text": "## Some panels!\n<details><summary>More</summary>Hidden content</details>",
"renderHtml": true
}
}
3 changes: 2 additions & 1 deletion markdown/sdk/go/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import "github.com/perses/perses/go-sdk/panel"
const PluginKind = "Markdown"

type PluginSpec struct {
Text string `json:"text" yaml:"text"`
Text string `json:"text" yaml:"text"`
RenderHTML bool `json:"renderHtml,omitempty" yaml:"renderHtml,omitempty"`
}

type Option func(plugin *Builder) error
Expand Down
7 changes: 7 additions & 0 deletions markdown/sdk/go/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ func NewLine(text string) Option {
return nil
}
}

func RenderHTML(render bool) Option {
return func(builder *Builder) error {
builder.RenderHTML = render
return nil
}
}
56 changes: 56 additions & 0 deletions markdown/src/MarkdownPanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, expect, it } from 'vitest';

import { markdownToHTML, sanitizeHTML } from './MarkdownPanel';

function render(text: string, renderHtml?: boolean): HTMLElement {
const container = document.createElement('div');
container.innerHTML = sanitizeHTML(markdownToHTML(text, renderHtml));
return container;
}

describe('markdownToHTML', () => {
it('escapes raw HTML by default', () => {
const container = render('Expected format: <host>:<port>');
expect(container.querySelector('p')?.textContent).toBe('Expected format: <host>:<port>');
});

it('escapes block-level HTML when disabled', () => {
const container = render('<details><summary>More</summary>Hidden</details>', false);
expect(container.querySelector('details')).toBeNull();
expect(container.textContent).toContain('<details><summary>More</summary>Hidden</details>');
});

it('still renders markdown when HTML is disabled', () => {
const container = render('**bold** `code` [link](https://example.com) <https://perses.dev>');
expect(container.querySelector('strong')?.textContent).toBe('bold');
expect(container.querySelector('code')?.textContent).toBe('code');
const links = container.querySelectorAll('a');
expect(links).toHaveLength(2);
expect(links[0]).toHaveAttribute('href', 'https://example.com');
expect(links[1]).toHaveAttribute('href', 'https://perses.dev');
});

it('renders raw HTML when enabled', () => {
const container = render('<details><summary>More</summary>Hidden</details>', true);
expect(container.querySelector('details summary')?.textContent).toBe('More');
});

it('sanitizes unsafe raw HTML when enabled', () => {
const container = render('<img src="x" onerror="alert(1)"><script>alert(2)</script>', true);
expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('img')).not.toHaveAttribute('onerror');
});
});
37 changes: 31 additions & 6 deletions markdown/src/MarkdownPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useChartsTheme } from '@perses-dev/components';
import type { PanelProps } from '@perses-dev/plugin-system';
import { useReplaceVariablesInString } from '@perses-dev/plugin-system';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { Marked } from 'marked';
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';

Expand Down Expand Up @@ -93,10 +93,32 @@ function createMarkdownPanelStyles(theme: Theme, chartsTheme: PersesChartsTheme)
};
}

// Convert markdown to HTML
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

function escapeHTML(text: string): string {
return text.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char] ?? char);
}

// Supports original markdown and GitHub Flavored markdown
function markdownToHTML(text: string): string {
return marked.parse(text, { gfm: true, async: false });
const markdownWithHTML = new Marked({ gfm: true });

// Raw HTML (block and inline) is escaped so it is displayed literally, e.g. `<host>:<port>`
const markdownWithoutHTML = new Marked({
gfm: true,
renderer: {
html: ({ text }): string => escapeHTML(text),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overriding the html renderer might not be enough to scape actual HTML. For example passing:

prefix <code><img/src=//example.com/tracker></code>

would still load an image.

We might need something like:

const markdownWithoutHTML = new Marked({
  gfm: true,
  tokenizer: {
    html(): undefined {
      return undefined;
    },
    tag(): undefined {
      return undefined;
    },
  },
});

},
});

export function markdownToHTML(text: string, renderHtml = false): string {
const parser = renderHtml ? markdownWithHTML : markdownWithoutHTML;
return parser.parse(text, { async: false });
}

// Prevent XSS attacks by removing the vectors for attacks
Expand All @@ -108,13 +130,16 @@ export function sanitizeHTML(html: string): string {

export function MarkdownPanel(props: MarkdownPanelProps): ReactElement {
const {
spec: { text },
spec: { text, renderHtml = false },
} = props;
const chartsTheme = useChartsTheme();

const textAfterVariableReplacement = useReplaceVariablesInString(text);

const html = useMemo(() => markdownToHTML(textAfterVariableReplacement ?? ''), [textAfterVariableReplacement]);
const html = useMemo(
() => markdownToHTML(textAfterVariableReplacement ?? '', renderHtml),
[textAfterVariableReplacement, renderHtml],
);
const sanitizedHTML = useMemo(() => sanitizeHTML(html), [html]);

return (
Expand Down
38 changes: 38 additions & 0 deletions markdown/src/MarkdownPanelOptionsEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { MarkdownPanelOptionsEditor } from './MarkdownPanelOptionsEditor';

describe('MarkdownPanelOptionsEditor', () => {
it('shows HTML rendering as disabled by default', () => {
render(<MarkdownPanelOptionsEditor value={{ text: 'hello' }} onChange={vi.fn()} />);
expect(screen.getByRole('checkbox', { name: /Render HTML/i })).not.toBeChecked();
});

it('enables HTML rendering while keeping the text', () => {
const onChange = vi.fn();
render(<MarkdownPanelOptionsEditor value={{ text: 'hello' }} onChange={onChange} />);
fireEvent.click(screen.getByRole('checkbox', { name: /Render HTML/i }));
expect(onChange).toHaveBeenCalledWith({ text: 'hello', renderHtml: true });
});

it('keeps renderHtml when the text changes', () => {
const onChange = vi.fn();
render(<MarkdownPanelOptionsEditor value={{ text: 'hello', renderHtml: true }} onChange={onChange} />);
fireEvent.change(screen.getByRole('textbox', { name: /Text/i }), { target: { value: 'world' } });
expect(onChange).toHaveBeenCalledWith({ text: 'world', renderHtml: true });
});
});
25 changes: 16 additions & 9 deletions markdown/src/MarkdownPanelOptionsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Link, Stack, TextField } from '@mui/material';
import { Link, Stack, Switch, TextField } from '@mui/material';
import { OptionsEditorControl } from '@perses-dev/components';
import type { OptionsEditorProps } from '@perses-dev/plugin-system';
import type { ChangeEvent, ReactElement } from 'react';

Expand All @@ -23,13 +24,14 @@ const MARKDOWN_GUIDE_URL = 'https://commonmark.org/help/';
const TEXT_INPUT_NUM_ROWS = 20;

export function MarkdownPanelOptionsEditor(props: MarkdownPanelOptionsEditorProps): ReactElement {
const {
onChange,
value: { text },
} = props;
const { onChange, value } = props;

const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
onChange({ text: e.target.value });
const handleTextChange = (e: ChangeEvent<HTMLInputElement>): void => {
onChange({ ...value, text: e.target.value });
};

const handleRenderHtmlChange = (_: ChangeEvent, checked: boolean): void => {
onChange({ ...value, renderHtml: checked });
};

return (
Expand All @@ -43,8 +45,13 @@ export function MarkdownPanelOptionsEditor(props: MarkdownPanelOptionsEditorProp
multiline
rows={TEXT_INPUT_NUM_ROWS}
// Value
value={text}
onChange={handleChange}
value={value.text}
onChange={handleTextChange}
/>
<OptionsEditorControl
label="Render HTML"
description="Render raw HTML tags found in the text. When disabled, HTML is displayed as plain text."
control={<Switch checked={!!value.renderHtml} onChange={handleRenderHtmlChange} />}
/>
</Stack>
);
Expand Down
5 changes: 5 additions & 0 deletions markdown/src/markdown-panel-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface MarkdownPanelDefinition extends Definition<MarkdownPanelOptions

export interface MarkdownPanelOptions {
text: string;
/**
* When true, raw HTML in the text is rendered (after sanitization).
* When false or omitted, raw HTML is escaped and displayed as plain text.
*/
renderHtml?: boolean;
}

export function createInitialMarkdownPanelOptions(): MarkdownPanelOptions {
Expand Down
Loading