Skip to content

Commit e1cf063

Browse files
committed
Merge branch 'pr/lovasoa/856'
2 parents fe3e6fc + 2bf0a2f commit e1cf063

File tree

6 files changed

+202
-32
lines changed

6 files changed

+202
-32
lines changed

examples/official-site/custom_components.sql

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Each page in SQLPage is composed of a `shell` component,
1616
which contains the page title and the navigation bar,
1717
and a series of normal components that display the data.
1818
19-
The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter.
19+
The `shell` component is always present unless explicitly skipped via the `?_sqlpage_embed` query parameter.
2020
If you don''t call it explicitly, it will be invoked with the default parameters automatically before your first component
2121
invocation that tries to render data on the page.
2222
@@ -88,7 +88,7 @@ For instance, you can easily create a multi-column layout with the following cod
8888
</div>
8989
```
9090
91-
For custom styling, you can write your own CSS files
91+
For custom styling, you can write your own CSS files
9292
and include them in your page header.
9393
You can use the `css` parameter of the default [`shell`](./documentation.sql?component=shell#component) component,
9494
or create your own custom `shell` component with a `<link>` tag.
@@ -132,7 +132,7 @@ and SQLPage adds a few more:
132132
- `static_path`: returns the path to one of the static files bundled with SQLPage. Accepts arguments like `sqlpage.js`, `sqlpage.css`, `apexcharts.js`, etc.
133133
- `app_config`: returns the value of a configuration parameter from sqlpage''s configuration file, such as `max_uploaded_file_size`, `site_prefix`, etc.
134134
- `icon_img`: generate an svg icon from a *tabler* icon name
135-
- `markdown`: renders markdown text
135+
- `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.
136136
- `each_row`: iterates over the rows of a query result
137137
- `typeof`: returns the type of a value (`string`, `number`, `boolean`, `object`, `array`, `null`)
138138
- `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:
178178
179179
- [The `shell` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/shell.handlebars)
180180
- [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.
181-
- [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses
181+
- [The `table` component](https://github.com/sqlpage/SQLPage/blob/main/sqlpage/templates/table.handlebars): more complex example of a component that uses
182182
- the `eq`, `or`, and `sort` handlebars helpers,
183183
- the `../` syntax to access the parent context,
184184
- and the `@key` to work with objects whose keys are not known in advance.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'text', * FROM (VALUES
2+
('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),
3+
('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)
4+
);

sqlpage/templates/text.handlebars

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
{{{~markdown contents_md~}}}
1212
</div>
1313
{{~/if~}}
14+
{{~#if unsafe_contents_md~}}
15+
<div class="remove-bottom-margin {{#if center}}mx-auto{{/if}} {{#if article}}markdown article-text{{/if}}">
16+
{{{~markdown unsafe_contents_md 'allow_unsafe'~}}}
17+
</div>
18+
{{~/if~}}
1419
<p class="{{#if center}}mx-auto{{/if}} {{#if article}}markdown article-text{{/if}}">
1520
{{contents}}
1621
{{~#each_row~}}
@@ -32,5 +37,8 @@
3237
{{~#if contents_md~}}
3338
{{{markdown contents_md}}}
3439
{{~/if~}}
40+
{{~#if unsafe_contents_md~}}
41+
{{{markdown unsafe_contents_md 'allow_unsafe'}}}
42+
{{~/if~}}
3543
{{~/each_row~}}
3644
</p>

src/template_helpers.rs

Lines changed: 182 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -251,28 +251,69 @@ fn typeof_helper(v: &JsonValue) -> JsonValue {
251251
.into()
252252
}
253253

254+
pub trait MarkdownConfig {
255+
fn allow_dangerous_html(&self) -> bool;
256+
fn allow_dangerous_protocol(&self) -> bool;
257+
}
258+
259+
impl MarkdownConfig for AppConfig {
260+
fn allow_dangerous_html(&self) -> bool {
261+
self.markdown_allow_dangerous_html
262+
}
263+
264+
fn allow_dangerous_protocol(&self) -> bool {
265+
self.markdown_allow_dangerous_protocol
266+
}
267+
}
268+
254269
/// Helper to render markdown with configurable options
270+
#[derive(Default)]
255271
struct MarkdownHelper {
256272
allow_dangerous_html: bool,
257273
allow_dangerous_protocol: bool,
258274
}
259275

260276
impl MarkdownHelper {
261-
fn new(config: &AppConfig) -> Self {
277+
fn new(config: &impl MarkdownConfig) -> Self {
262278
Self {
263-
allow_dangerous_html: config.markdown_allow_dangerous_html,
264-
allow_dangerous_protocol: config.markdown_allow_dangerous_protocol,
279+
allow_dangerous_html: config.allow_dangerous_html(),
280+
allow_dangerous_protocol: config.allow_dangerous_protocol(),
281+
}
282+
}
283+
284+
fn get_preset_options(&self, preset_name: &str) -> Result<markdown::Options, String> {
285+
let mut options = markdown::Options::gfm();
286+
options.compile.allow_dangerous_html = self.allow_dangerous_html;
287+
options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
288+
options.compile.allow_any_img_src = true;
289+
290+
match preset_name {
291+
"default" => {}
292+
"allow_unsafe" => {
293+
options.compile.allow_dangerous_html = true;
294+
options.compile.allow_dangerous_protocol = true;
295+
}
296+
_ => return Err(format!("unknown markdown preset: {preset_name}")),
265297
}
298+
299+
Ok(options)
266300
}
267301
}
268302

269303
impl CanHelp for MarkdownHelper {
270304
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
271-
let as_str = match args {
272-
[v] => v.value(),
273-
_ => return Err("expected one argument".to_string()),
305+
let (markdown_src_value, preset_name) = match args {
306+
[v] => (v.value(), "default"),
307+
[v, preset] => (
308+
v.value(),
309+
preset
310+
.value()
311+
.as_str()
312+
.ok_or("markdown template helper expects a string as preset name")?,
313+
),
314+
_ => return Err("markdown template helper expects one or two arguments".to_string()),
274315
};
275-
let as_str = match as_str {
316+
let markdown_src = match markdown_src_value {
276317
JsonValue::String(s) => Cow::Borrowed(s),
277318
JsonValue::Array(arr) => Cow::Owned(
278319
arr.iter()
@@ -283,11 +324,9 @@ impl CanHelp for MarkdownHelper {
283324
JsonValue::Null => Cow::Owned(String::new()),
284325
other => Cow::Owned(other.to_string()),
285326
};
286-
let mut options = markdown::Options::gfm();
287-
options.compile.allow_dangerous_html = self.allow_dangerous_html;
288-
options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
289-
options.compile.allow_any_img_src = true;
290-
markdown::to_html_with_options(&as_str, &options)
327+
328+
let options = self.get_preset_options(preset_name)?;
329+
markdown::to_html_with_options(&markdown_src, &options)
291330
.map(JsonValue::String)
292331
.map_err(|e| e.to_string())
293332
}
@@ -543,20 +582,135 @@ fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValu
543582
text_str.replace(original_str, replacement_str).into()
544583
}
545584

546-
#[test]
547-
fn test_rfc2822_date() {
548-
assert_eq!(
549-
rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
550-
.unwrap()
551-
.as_str()
552-
.unwrap(),
553-
"Fri, 02 Jan 1970 03:04:05 +0200"
554-
);
555-
assert_eq!(
556-
rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
557-
.unwrap()
558-
.as_str()
559-
.unwrap(),
560-
"Fri, 02 Jan 1970 00:00:00 +0000"
561-
);
585+
#[cfg(test)]
586+
mod tests {
587+
use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper};
588+
use handlebars::{JsonValue, PathAndJson, ScopedJson};
589+
use serde_json::Value;
590+
591+
const CONTENT_KEY: &'static str = "contents_md";
592+
593+
#[test]
594+
fn test_rfc2822_date() {
595+
assert_eq!(
596+
rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
597+
.unwrap()
598+
.as_str()
599+
.unwrap(),
600+
"Fri, 02 Jan 1970 03:04:05 +0200"
601+
);
602+
assert_eq!(
603+
rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
604+
.unwrap()
605+
.as_str()
606+
.unwrap(),
607+
"Fri, 02 Jan 1970 00:00:00 +0000"
608+
);
609+
}
610+
611+
#[test]
612+
fn test_basic_gfm_markdown() {
613+
let helper = MarkdownHelper::default();
614+
615+
let contents = Value::String("# Heading".to_string());
616+
let actual = helper.call(&as_args(&contents)).unwrap();
617+
618+
assert_eq!(Some("<h1>Heading</h1>"), actual.as_str());
619+
}
620+
621+
// Optionally allow potentially unsafe html blocks
622+
// See https://spec.commonmark.org/0.31.2/#html-blocks
623+
mod markdown_html_blocks {
624+
625+
use super::*;
626+
627+
const UNSAFE_MARKUP: &'static str = "<table><tr><td>";
628+
const ESCAPED_UNSAFE_MARKUP: &'static str = "&lt;table&gt;&lt;tr&gt;&lt;td&gt;";
629+
630+
#[test]
631+
fn test_html_blocks_are_not_allowed_by_default() {
632+
let helper = MarkdownHelper::default();
633+
let actual = helper.call(&as_args(&contents())).unwrap();
634+
635+
assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
636+
}
637+
638+
#[test]
639+
fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_undefined() {
640+
let helper = MarkdownHelper::default();
641+
let allow_unsafe = Value::Null;
642+
let actual = helper
643+
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
644+
.unwrap();
645+
646+
assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
647+
}
648+
649+
#[test]
650+
fn test_html_blocks_are_not_allowed_when_allow_unsafe_is_false() {
651+
let helper = MarkdownHelper::default();
652+
let allow_unsafe = Value::Bool(false);
653+
let actual = helper
654+
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
655+
.unwrap();
656+
657+
assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
658+
}
659+
660+
#[test]
661+
fn test_html_blocks_are_not_allowed_when_allow_unsafe_option_is_missing() {
662+
let helper = MarkdownHelper::default();
663+
let allow_unsafe = ScopedJson::Missing;
664+
let actual = helper
665+
.call(&[
666+
as_helper_arg(CONTENT_KEY, &contents()),
667+
to_path_and_json(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe),
668+
])
669+
.unwrap();
670+
671+
assert_eq!(Some(ESCAPED_UNSAFE_MARKUP), actual.as_str());
672+
}
673+
674+
#[test]
675+
fn test_html_blocks_are_allowed_when_allow_unsafe_is_true() {
676+
let helper = MarkdownHelper::default();
677+
let allow_unsafe = Value::String(String::from(MarkdownHelper::ALLOW_UNSAFE));
678+
let actual = helper
679+
.call(&as_args_with_unsafe(&contents(), &allow_unsafe))
680+
.unwrap();
681+
682+
assert_eq!(Some(UNSAFE_MARKUP), actual.as_str());
683+
}
684+
685+
fn as_args_with_unsafe<'a>(
686+
contents: &'a Value,
687+
allow_unsafe: &'a Value,
688+
) -> [PathAndJson<'a>; 2] {
689+
[
690+
as_helper_arg(CONTENT_KEY, contents),
691+
as_helper_arg(MarkdownHelper::ALLOW_UNSAFE, allow_unsafe),
692+
]
693+
}
694+
695+
fn contents() -> Value {
696+
Value::String(UNSAFE_MARKUP.to_string())
697+
}
698+
}
699+
700+
fn as_args(contents: &Value) -> [PathAndJson; 1] {
701+
[as_helper_arg(CONTENT_KEY, contents)]
702+
}
703+
704+
fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> {
705+
let json_context = as_json_context(path, value);
706+
to_path_and_json(path, json_context)
707+
}
708+
709+
fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> {
710+
PathAndJson::new(Some(path.to_string()), value)
711+
}
712+
713+
fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
714+
ScopedJson::Context(value, vec![path.to_string()])
715+
}
562716
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
select 'text' as component,
2+
'### It works !' AS contents_md;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
select 'text' as component,
2+
'<span>It works !</span>' AS unsafe_contents_md;

0 commit comments

Comments
 (0)