From 2702cbcc364ec4bce9adbcf65d918e211ae1dee9 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Thu, 13 Mar 2025 11:15:20 +0000 Subject: [PATCH 1/7] introduce MarkdownConfig trait to simplify testing; introduce allow_unsafe optional parameter to MarkdownHelper, add tests for various permutations, extend text.handlebars to pass through parameter --- sqlpage/templates/text.handlebars | 4 +- src/template_helpers.rs | 212 ++++++++++++++++++++++++++---- 2 files changed, 190 insertions(+), 26 deletions(-) diff --git a/sqlpage/templates/text.handlebars b/sqlpage/templates/text.handlebars index 30e6887f..8d6eb60a 100644 --- a/sqlpage/templates/text.handlebars +++ b/sqlpage/templates/text.handlebars @@ -8,7 +8,7 @@ {{~/if~}} {{~#if contents_md~}}
- {{{~markdown contents_md~}}} + {{{~markdown contents_md unsafe~}}}
{{~/if~}}

@@ -30,7 +30,7 @@ ">{{contents}} {{~flush_delayed~}} {{~#if contents_md~}} - {{{markdown contents_md}}} + {{{markdown contents_md unsafe}}} {{~/if~}} {{~/each_row~}}

diff --git a/src/template_helpers.rs b/src/template_helpers.rs index c05c1f86..79159527 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -6,6 +6,7 @@ use handlebars::{ handlebars_helper, Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError, RenderErrorReason, Renderable, ScopedJson, }; +use markdown::Options; use serde_json::Value as JsonValue; /// Simple static json helper @@ -251,25 +252,76 @@ 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 struct MarkdownHelper { allow_dangerous_html: bool, allow_dangerous_protocol: bool, } +impl Default for MarkdownHelper { + fn default() -> Self { + Self { + allow_dangerous_html: false, + allow_dangerous_protocol: false, + } + } +} + 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]) -> Options { + let mut options = self.system_options(); + + if !options.compile.allow_dangerous_html && args.len() > 1 { + let arg = &args[1]; + if arg.relative_path() == Some(&Self::ALLOW_UNSAFE.to_string()) { + options.compile.allow_dangerous_html = arg.value() == &JsonValue::Bool(true) + } + } + + options + } + + fn system_options(&self) -> Options { + let mut options = 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(), + [v] | [v, _] => v.value(), _ => return Err("expected one argument".to_string()), }; let as_str = match as_str { @@ -283,10 +335,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 +592,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 handlebars::{JsonValue, PathAndJson, ScopedJson}; + use serde_json::Value; + use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper}; + + 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::Bool(true); + 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()]) + } + + } From 7d93aa605073ac003eab511e279c813b8025555d Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sat, 15 Mar 2025 09:08:11 +0000 Subject: [PATCH 2/7] rename unsafe -> allow_unsafe (unsafe was a bad choice as it clashes with the rust keyword) --- sqlpage/templates/text.handlebars | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlpage/templates/text.handlebars b/sqlpage/templates/text.handlebars index 8d6eb60a..0ffd73d9 100644 --- a/sqlpage/templates/text.handlebars +++ b/sqlpage/templates/text.handlebars @@ -8,7 +8,7 @@ {{~/if~}} {{~#if contents_md~}}
- {{{~markdown contents_md unsafe~}}} + {{{~markdown contents_md allow_unsafe~}}}
{{~/if~}}

@@ -30,7 +30,7 @@ ">{{contents}} {{~flush_delayed~}} {{~#if contents_md~}} - {{{markdown contents_md unsafe}}} + {{{markdown contents_md allow_unsafe}}} {{~/if~}} {{~/each_row~}}

From 8c7d6c75825e454ee1f9f778a33cca24cb3c5c47 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 17 Mar 2025 10:07:05 +0000 Subject: [PATCH 3/7] inline markdown::Option import --- src/template_helpers.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/template_helpers.rs b/src/template_helpers.rs index 79159527..963e0a37 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -6,7 +6,6 @@ use handlebars::{ handlebars_helper, Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError, RenderErrorReason, Renderable, ScopedJson, }; -use markdown::Options; use serde_json::Value as JsonValue; /// Simple static json helper @@ -293,7 +292,7 @@ impl MarkdownHelper { } } - fn calculate_options(&self, args: &[PathAndJson]) -> Options { + fn calculate_options(&self, args: &[PathAndJson]) -> markdown::Options { let mut options = self.system_options(); if !options.compile.allow_dangerous_html && args.len() > 1 { @@ -306,8 +305,8 @@ impl MarkdownHelper { options } - fn system_options(&self) -> Options { - let mut options = Options::gfm(); + 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; From 1fe7687917b93f3a0c6bb0fe8639ccf13242683e Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 17 Mar 2025 10:22:04 +0000 Subject: [PATCH 4/7] format and switch to use arg.value() and args.get(1) --- src/template_helpers.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/template_helpers.rs b/src/template_helpers.rs index 963e0a37..6ecf0a95 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -282,7 +282,6 @@ impl Default for MarkdownHelper { } impl MarkdownHelper { - const ALLOW_UNSAFE: &'static str = "allow_unsafe"; fn new(config: &impl MarkdownConfig) -> Self { @@ -296,9 +295,10 @@ impl MarkdownHelper { let mut options = self.system_options(); if !options.compile.allow_dangerous_html && args.len() > 1 { - let arg = &args[1]; - if arg.relative_path() == Some(&Self::ALLOW_UNSAFE.to_string()) { - options.compile.allow_dangerous_html = arg.value() == &JsonValue::Bool(true) + if let Some(arg) = args.get(1) { + if arg.value().as_str() == Some(Self::ALLOW_UNSAFE) { + options.compile.allow_dangerous_html = true + } } } @@ -313,7 +313,6 @@ impl MarkdownHelper { options } - } impl CanHelp for MarkdownHelper { @@ -321,7 +320,7 @@ impl CanHelp for MarkdownHelper { let options = self.calculate_options(args); let as_str = match args { [v] | [v, _] => v.value(), - _ => return Err("expected one argument".to_string()), + _ => return Err("expected one or two arguments".to_string()), }; let as_str = match as_str { JsonValue::String(s) => Cow::Borrowed(s), @@ -593,9 +592,9 @@ fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValu #[cfg(test)] mod tests { + use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper}; use handlebars::{JsonValue, PathAndJson, ScopedJson}; use serde_json::Value; - use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper}; const CONTENT_KEY: &'static str = "contents_md"; @@ -673,7 +672,7 @@ mod tests { let actual = helper .call(&[ as_helper_arg(CONTENT_KEY, &contents()), - to_path_and_json(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe) + to_path_and_json(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe), ]) .unwrap(); @@ -683,7 +682,7 @@ mod tests { #[test] fn test_html_blocks_are_allowed_when_allow_unsafe_is_true() { let helper = MarkdownHelper::default(); - let allow_unsafe = Value::Bool(true); + let allow_unsafe = Value::String(String::from(MarkdownHelper::ALLOW_UNSAFE)); let actual = helper .call(&as_args_with_unsafe(&contents(), &allow_unsafe)) .unwrap(); @@ -691,7 +690,10 @@ mod tests { assert_eq!(Some(UNSAFE_MARKUP), actual.as_str()); } - fn as_args_with_unsafe<'a>(contents: &'a Value, allow_unsafe: &'a Value) -> [PathAndJson<'a>; 2] { + 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), @@ -701,7 +703,6 @@ mod tests { fn contents() -> Value { Value::String(UNSAFE_MARKUP.to_string()) } - } fn as_args(contents: &Value) -> [PathAndJson; 1] { @@ -720,6 +721,4 @@ mod tests { fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> { ScopedJson::Context(value, vec![path.to_string()]) } - - } From 2cfbe4bda65246b0ca1df93231dcf53bf9ebca11 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 17 Mar 2025 10:27:54 +0000 Subject: [PATCH 5/7] clippy fix --- src/template_helpers.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/template_helpers.rs b/src/template_helpers.rs index 6ecf0a95..7be62c9b 100644 --- a/src/template_helpers.rs +++ b/src/template_helpers.rs @@ -267,20 +267,12 @@ impl MarkdownConfig for AppConfig { } /// Helper to render markdown with configurable options +#[derive(Default)] struct MarkdownHelper { allow_dangerous_html: bool, allow_dangerous_protocol: bool, } -impl Default for MarkdownHelper { - fn default() -> Self { - Self { - allow_dangerous_html: false, - allow_dangerous_protocol: false, - } - } -} - impl MarkdownHelper { const ALLOW_UNSAFE: &'static str = "allow_unsafe"; @@ -297,7 +289,7 @@ impl MarkdownHelper { 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.compile.allow_dangerous_html = true; } } } From 261f76c2bc373a0790c2bfb320438c386b96495d Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 17 Mar 2025 11:08:36 +0000 Subject: [PATCH 6/7] Add `unsafe_contents_md` to text component, add related file tests, add documentation parameters --- .../sqlpage/migrations/59_unsafe_contents_md.sql | 4 ++++ sqlpage/templates/text.handlebars | 12 ++++++++++-- tests/sql_test_files/it_works_text_markdown.sql | 2 ++ .../sql_test_files/it_works_text_unsafe_markdown.sql | 2 ++ 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 examples/official-site/sqlpage/migrations/59_unsafe_contents_md.sql create mode 100644 tests/sql_test_files/it_works_text_markdown.sql create mode 100644 tests/sql_test_files/it_works_text_unsafe_markdown.sql 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 0ffd73d9..5b28a44e 100644 --- a/sqlpage/templates/text.handlebars +++ b/sqlpage/templates/text.handlebars @@ -8,7 +8,12 @@ {{~/if~}} {{~#if contents_md~}}
- {{{~markdown contents_md allow_unsafe~}}} + {{{~markdown contents_md~}}} +
+{{~/if~}} +{{~#if unsafe_contents_md~}} +
+ {{{~markdown unsafe_contents_md 'allow_unsafe'~}}}
{{~/if~}}

@@ -30,7 +35,10 @@ ">{{contents}} {{~flush_delayed~}} {{~#if contents_md~}} - {{{markdown contents_md allow_unsafe}}} + {{{markdown contents_md}}} + {{~/if~}} + {{~#if unsafe_contents_md~}} + {{{markdown unsafe_contents_md 'allow_unsafe'}}} {{~/if~}} {{~/each_row~}}

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; From f0329cd04d7a74fbd6b8a33be9c6dd686fc56a6f Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 17 Mar 2025 11:13:48 +0000 Subject: [PATCH 7/7] Extend custom component description for markdown helper --- examples/official-site/custom_components.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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.