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~}} +
+ {{{~markdown unsafe_contents_md 'allow_unsafe'~}}} +
+{{~/if~}}

{{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 { + let options = self.calculate_options(args); let as_str = match args { - [v] => v.value(), - _ => return Err("expected one argument".to_string()), + [v] | [v, _] => v.value(), + _ => return Err("expected one or two arguments".to_string()), }; let as_str = match as_str { JsonValue::String(s) => Cow::Borrowed(s), @@ -283,10 +325,7 @@ impl CanHelp for MarkdownHelper { JsonValue::Null => Cow::Owned(String::new()), other => Cow::Owned(other.to_string()), }; - 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; + markdown::to_html_with_options(&as_str, &options) .map(JsonValue::String) .map_err(|e| e.to_string()) @@ -543,20 +582,135 @@ fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValu text_str.replace(original_str, replacement_str).into() } -#[test] -fn test_rfc2822_date() { - assert_eq!( - rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into())) - .unwrap() - .as_str() - .unwrap(), - "Fri, 02 Jan 1970 03:04:05 +0200" - ); - assert_eq!( - rfc2822_date_helper(&JsonValue::String("1970-01-02".into())) - .unwrap() - .as_str() - .unwrap(), - "Fri, 02 Jan 1970 00:00:00 +0000" - ); +#[cfg(test)] +mod tests { + use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper}; + use handlebars::{JsonValue, PathAndJson, ScopedJson}; + use serde_json::Value; + + const CONTENT_KEY: &'static str = "contents_md"; + + #[test] + fn test_rfc2822_date() { + assert_eq!( + rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into())) + .unwrap() + .as_str() + .unwrap(), + "Fri, 02 Jan 1970 03:04:05 +0200" + ); + assert_eq!( + rfc2822_date_helper(&JsonValue::String("1970-01-02".into())) + .unwrap() + .as_str() + .unwrap(), + "Fri, 02 Jan 1970 00:00:00 +0000" + ); + } + + #[test] + fn test_basic_gfm_markdown() { + let helper = MarkdownHelper::default(); + + let contents = Value::String("# Heading".to_string()); + let actual = helper.call(&as_args(&contents)).unwrap(); + + assert_eq!(Some("

Heading

"), actual.as_str()); + } + + // Optionally allow potentially unsafe html blocks + // See https://spec.commonmark.org/0.31.2/#html-blocks + mod markdown_html_blocks { + + use super::*; + + const UNSAFE_MARKUP: &'static str = "
"; + 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;