diff --git a/examples/official-site/custom_components.sql b/examples/official-site/custom_components.sql index b1a57ad6..ff2902a7 100644 --- a/examples/official-site/custom_components.sql +++ b/examples/official-site/custom_components.sql @@ -16,7 +16,7 @@ Each page in SQLPage is composed of a `shell` component, which contains the page title and the navigation bar, and a series of normal components that display the data. -The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter. +The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter. If you don''t call it explicitly, it will be invoked with the default parameters automatically before your first component invocation that tries to render data on the page. @@ -88,7 +88,7 @@ For instance, you can easily create a multi-column layout with the following cod ``` -For custom styling, you can write your own CSS files +For custom styling, you can write your own CSS files and include them in your page header. You can use the `css` parameter of the default [`shell`](./documentation.sql?component=shell#component) component, or create your own custom `shell` component with a `` tag. @@ -132,7 +132,7 @@ and SQLPage adds a few more: - `static_path`: returns the path to one of the static files bundled with SQLPage. Accepts arguments like `sqlpage.js`, `sqlpage.css`, `apexcharts.js`, etc. - `app_config`: returns the value of a configuration parameter from sqlpage''s configuration file, such as `max_uploaded_file_size`, `site_prefix`, etc. - `icon_img`: generate an svg icon from a *tabler* icon name -- `markdown`: renders markdown text +- `markdown`: renders markdown text. Accepts an optional 2nd argument `''allow_unsafe''` that will render embedded html blocks: use only on trusted content. See the [Commonmark spec](https://spec.commonmark.org/0.31.2/#html-blocks) for more info. - `each_row`: iterates over the rows of a query result - `typeof`: returns the type of a value (`string`, `number`, `boolean`, `object`, `array`, `null`) - `rfc2822_date`: formats a date as a string in the [RFC 2822](https://tools.ietf.org/html/rfc2822#section-3.3) format, that is, `Thu, 21 Dec 2000 16:01:07 +0200` @@ -178,7 +178,7 @@ Some interesting examples are: - [The `shell` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/shell.handlebars) - [The `card` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/card.handlebars): simple yet complete example of a component that displays a list of items. - - [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses + - [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses - the `eq`, `or`, and `sort` handlebars helpers, - the `../` syntax to access the parent context, - and the `@key` to work with objects whose keys are not known in advance. diff --git a/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql b/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql new file mode 100644 index 00000000..5f8f2c97 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql @@ -0,0 +1,4 @@ +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'text', * FROM (VALUES +('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', TRUE, TRUE), +('unsafe_contents_md','Markdown format with html blocks. Use this only with trusted content. See the html-blocks section of the Commonmark spec for additional info.', 'TEXT', FALSE, TRUE) +); diff --git a/sqlpage/templates/text.handlebars b/sqlpage/templates/text.handlebars index 30e6887f..5b28a44e 100644 --- a/sqlpage/templates/text.handlebars +++ b/sqlpage/templates/text.handlebars @@ -11,6 +11,11 @@ {{{~markdown contents_md~}}} {{~/if~}} +{{~#if unsafe_contents_md~}} +
{{contents}} {{~#each_row~}} @@ -32,5 +37,8 @@ {{~#if contents_md~}} {{{markdown contents_md}}} {{~/if~}} + {{~#if unsafe_contents_md~}} + {{{markdown unsafe_contents_md 'allow_unsafe'}}} + {{~/if~}} {{~/each_row~}}
diff --git a/src/template_helpers.rs b/src/template_helpers.rs index c05c1f86..7be62c9b 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -251,26 +251,68 @@ fn typeof_helper(v: &JsonValue) -> JsonValue { .into() } +pub trait MarkdownConfig { + fn allow_dangerous_html(&self) -> bool; + fn allow_dangerous_protocol(&self) -> bool; +} + +impl MarkdownConfig for AppConfig { + fn allow_dangerous_html(&self) -> bool { + self.markdown_allow_dangerous_html + } + + fn allow_dangerous_protocol(&self) -> bool { + self.markdown_allow_dangerous_protocol + } +} + /// Helper to render markdown with configurable options +#[derive(Default)] struct MarkdownHelper { allow_dangerous_html: bool, allow_dangerous_protocol: bool, } impl MarkdownHelper { - fn new(config: &AppConfig) -> Self { + const ALLOW_UNSAFE: &'static str = "allow_unsafe"; + + fn new(config: &impl MarkdownConfig) -> Self { Self { - allow_dangerous_html: config.markdown_allow_dangerous_html, - allow_dangerous_protocol: config.markdown_allow_dangerous_protocol, + allow_dangerous_html: config.allow_dangerous_html(), + allow_dangerous_protocol: config.allow_dangerous_protocol(), } } + + fn calculate_options(&self, args: &[PathAndJson]) -> markdown::Options { + let mut options = self.system_options(); + + if !options.compile.allow_dangerous_html && args.len() > 1 { + if let Some(arg) = args.get(1) { + if arg.value().as_str() == Some(Self::ALLOW_UNSAFE) { + options.compile.allow_dangerous_html = true; + } + } + } + + options + } + + fn system_options(&self) -> markdown::Options { + let mut options = markdown::Options::gfm(); + options.compile.allow_dangerous_html = self.allow_dangerous_html; + options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol; + options.compile.allow_any_img_src = true; + + options + } } impl CanHelp for MarkdownHelper { fn call(&self, args: &[PathAndJson]) -> Result"; + const ESCAPED_UNSAFE_MARKUP: &'static str = "<table><tr><td>"; + + #[test] + fn test_html_blocks_are_not_allowed_by_default() { + let helper = MarkdownHelper::default(); + let actual = helper.call(&as_args(&contents())).unwrap(); + + assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str()); + } + + #[test] + fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_undefined() { + let helper = MarkdownHelper::default(); + let allow_unsafe = Value::Null; + let actual = helper + .call(&as_args_with_unsafe(&contents(), &allow_unsafe)) + .unwrap(); + + assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str()); + } + + #[test] + fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_false() { + let helper = MarkdownHelper::default(); + let allow_unsafe = Value::Bool(false); + let actual = helper + .call(&as_args_with_unsafe(&contents(), &allow_unsafe)) + .unwrap(); + + assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str()); + } + + #[test] + fn test_html_blocks_are_not_allowed_when_allow_unsafe_option_is_missing() { + let helper = MarkdownHelper::default(); + let allow_unsafe = ScopedJson::Missing; + let actual = helper + .call(&[ + as_helper_arg(CONTENT_KEY, &contents()), + to_path_and_json(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe), + ]) + .unwrap(); + + assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str()); + } + + #[test] + fn test_html_blocks_are_allowed_when_allow_unsafe_is_true() { + let helper = MarkdownHelper::default(); + let allow_unsafe = Value::String(String::from(MarkdownHelper::ALLOW_UNSAFE)); + let actual = helper + .call(&as_args_with_unsafe(&contents(), &allow_unsafe)) + .unwrap(); + + assert_eq!(Some(UNSAFE_MARKUP), actual.as_str()); + } + + fn as_args_with_unsafe<'a>( + contents: &'a Value, + allow_unsafe: &'a Value, + ) -> [PathAndJson<'a>; 2] { + [ + as_helper_arg(CONTENT_KEY, contents), + as_helper_arg(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe), + ] + } + + fn contents() -> Value { + Value::String(UNSAFE_MARKUP.to_string()) + } + } + + fn as_args(contents: &Value) -> [PathAndJson; 1] { + [as_helper_arg(CONTENT_KEY, contents)] + } + + fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> { + let json_context = as_json_context(path, value); + to_path_and_json(path, json_context) + } + + fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> { + PathAndJson::new(Some(path.to_string()), value) + } + + fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> { + ScopedJson::Context(value, vec![path.to_string()]) + } } diff --git a/tests/sql_test_files/it_works_text_markdown.sql b/tests/sql_test_files/it_works_text_markdown.sql new file mode 100644 index 00000000..cf3354f7 --- /dev/null +++ b/tests/sql_test_files/it_works_text_markdown.sql @@ -0,0 +1,2 @@ +select 'text' as component, + '### It works !' AS contents_md; diff --git a/tests/sql_test_files/it_works_text_unsafe_markdown.sql b/tests/sql_test_files/it_works_text_unsafe_markdown.sql new file mode 100644 index 00000000..7f004ee6 --- /dev/null +++ b/tests/sql_test_files/it_works_text_unsafe_markdown.sql @@ -0,0 +1,2 @@ +select 'text' as component, + 'It works !' AS unsafe_contents_md; |