use std::{borrow::Cow, collections::HashMap, sync::LazyLock}; use crate::{app_config::AppConfig, utils::static_filename}; use anyhow::Context as _; use handlebars::{ Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError, RenderErrorReason, Renderable, ScopedJson, handlebars_helper, }; use serde_json::Value as JsonValue; /// Simple static json helper type H0 = fn() -> JsonValue; /// Simple json to json helper type H = fn(&JsonValue) -> JsonValue; /// Simple json to json helper with error handling type EH = fn(&JsonValue) -> anyhow::Result; /// Helper that takes two arguments type HH = fn(&JsonValue, &JsonValue) -> JsonValue; /// Helper that takes three arguments #[allow(clippy::upper_case_acronyms)] type HHH = fn(&JsonValue, &JsonValue, &JsonValue) -> JsonValue; pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) { let site_prefix = config.site_prefix.clone(); register_helper(h, "all", HelperCheckTruthy(false)); register_helper(h, "any", HelperCheckTruthy(true)); register_helper(h, "stringify", stringify_helper as H); register_helper(h, "parse_json", parse_json_helper as EH); register_helper(h, "default", default_helper as HH); register_helper(h, "entries", entries_helper as H); register_helper(h, "replace", replace_helper as HHH); // delay helper: store a piece of information in memory that can be output later with flush_delayed h.register_helper("delay", Box::new(delay_helper)); h.register_helper("flush_delayed", Box::new(flush_delayed_helper)); register_helper(h, "plus", plus_helper as HH); register_helper(h, "minus", minus_helper as HH); h.register_helper("sum", Box::new(sum_helper)); register_helper(h, "loose_eq", loose_eq_helper as HH); register_helper(h, "starts_with", starts_with_helper as HH); // to_array: convert a value to a single-element array. If the value is already an array, return it as-is. register_helper(h, "to_array", to_array_helper as H); // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument. handlebars_helper!(array_contains: |array: Json, element: Json| match array { JsonValue::Array(arr) => arr.contains(element), other => other == element }); h.register_helper("array_contains", Box::new(array_contains)); // array_contains_case_insensitive: check if an array contains an element case-insensitively. If the first argument is not an array, it is compared to the second argument case-insensitively. handlebars_helper!(array_contains_case_insensitive: |array: Json, element: Json| { match array { JsonValue::Array(arr) => arr.iter().any(|v| json_eq_case_insensitive(v, element)), other => json_eq_case_insensitive(other, element), } }); h.register_helper( "array_contains_case_insensitive", Box::new(array_contains_case_insensitive), ); // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage..js register_helper(h, "static_path", StaticPathHelper(site_prefix.clone())); register_helper(h, "app_config", AppConfigHelper(config.clone())); // icon helper: generate an image with the specified icon h.register_helper("icon_img", Box::new(IconImgHelper)); register_helper(h, "markdown", MarkdownHelper::new(config)); register_helper(h, "buildinfo", buildinfo_helper as EH); register_helper(h, "typeof", typeof_helper as H); register_helper(h, "rfc2822_date", rfc2822_date_helper as EH); register_helper(h, "url_encode", url_encode_helper as H); register_helper(h, "csv_escape", csv_escape_helper as HH); } fn json_eq_case_insensitive(a: &JsonValue, b: &JsonValue) -> bool { match (a, b) { (JsonValue::String(a), JsonValue::String(b)) => a.eq_ignore_ascii_case(b), _ => a == b, } } fn stringify_helper(v: &JsonValue) -> JsonValue { v.to_string().into() } fn parse_json_helper(v: &JsonValue) -> Result { Ok(match v { serde_json::value::Value::String(s) => serde_json::from_str(s)?, other => other.clone(), }) } fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue { if v.is_null() { default.clone() } else { v.clone() } } fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) { (a + b).into() } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) { (a + b).into() } else { JsonValue::Null } } fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) { (a - b).into() } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) { (a - b).into() } else { JsonValue::Null } } fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) { a.starts_with(b) } else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) { arr1.starts_with(arr2) } else { false } .into() } fn entries_helper(v: &JsonValue) -> JsonValue { match v { serde_json::value::Value::Object(map) => map .into_iter() .map(|(k, v)| serde_json::json!({"key": k, "value": v})) .collect(), serde_json::value::Value::Array(values) => values .iter() .enumerate() .map(|(k, v)| serde_json::json!({"key": k, "value": v})) .collect(), _ => vec![], } .into() } fn to_array_helper(v: &JsonValue) -> JsonValue { match v { JsonValue::Array(arr) => arr.clone(), JsonValue::Null => vec![], JsonValue::String(s) if s.starts_with('[') => { if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) { r } else { vec![JsonValue::String(s.clone())] } } other => vec![other.clone()], } .into() } /// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix struct StaticPathHelper(String); impl CanHelp for StaticPathHelper { fn call(&self, args: &[PathAndJson]) -> Result { let static_file = match args { [v] => v.value(), _ => return Err("expected one argument".to_string()), }; let name = static_file .as_str() .ok_or_else(|| format!("static_path: not a string: {static_file}"))?; let path = match name { "sqlpage.js" => static_filename!("sqlpage.js"), "sqlpage.css" => static_filename!("sqlpage.css"), "apexcharts.js" => static_filename!("apexcharts.js"), "tomselect.js" => static_filename!("tomselect.js"), "favicon.svg" => static_filename!("favicon.svg"), other => return Err(format!("unknown static file: {other:?}")), }; Ok(format!("{}{}", self.0, path).into()) } } /// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix struct AppConfigHelper(AppConfig); impl CanHelp for AppConfigHelper { fn call(&self, args: &[PathAndJson]) -> Result { let static_file = match args { [v] => v.value(), _ => return Err("expected one argument".to_string()), }; let name = static_file .as_str() .ok_or_else(|| format!("app_config: not a string: {static_file}"))?; match name { "max_uploaded_file_size" => Ok(JsonValue::Number(self.0.max_uploaded_file_size.into())), "environment" => serde_json::to_value(self.0.environment).map_err(|e| e.to_string()), "site_prefix" => Ok(self.0.site_prefix.clone().into()), other => Err(format!("unknown app config property: {other:?}")), } } } pub static ICON_MAP: LazyLock> = LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/icons.rs")).into()); /// Generate an image with the specified icon. struct IconImgHelper; impl HelperDef for IconImgHelper { fn call<'reg: 'rc, 'rc>( &self, helper: &handlebars::Helper<'rc>, _r: &'reg Handlebars<'reg>, _ctx: &'rc Context, _rc: &mut handlebars::RenderContext<'reg, 'rc>, writer: &mut dyn handlebars::Output, ) -> handlebars::HelperResult { let null = handlebars::JsonValue::Null; let [name, size] = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value)); let size = size.as_u64().unwrap_or(24); let content = name.as_str().and_then(|name| ICON_MAP.get(name)); let Some(&inner_content) = content else { log::warn!("icon_img: icon {name} not found"); return Ok(()); }; write!( writer, r#"{inner_content}"# )?; Ok(()) } } fn typeof_helper(v: &JsonValue) -> JsonValue { match v { JsonValue::Null => "null", JsonValue::Bool(_) => "boolean", JsonValue::Number(_) => "number", JsonValue::String(_) => "string", JsonValue::Array(_) => "array", JsonValue::Object(_) => "object", } .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: &impl MarkdownConfig) -> Self { Self { allow_dangerous_html: config.allow_dangerous_html(), allow_dangerous_protocol: config.allow_dangerous_protocol(), } } fn get_preset_options(&self, preset_name: &str) -> Result { 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; match preset_name { "default" => {} "allow_unsafe" => { options.compile.allow_dangerous_html = true; options.compile.allow_dangerous_protocol = true; } _ => return Err(format!("unknown markdown preset: {preset_name}")), } Ok(options) } } impl CanHelp for MarkdownHelper { fn call(&self, args: &[PathAndJson]) -> Result { let (markdown_src_value, preset_name) = match args { [v] => (v.value(), "default"), [v, preset] => { let value = v.value(); let preset_name_value = preset.value(); let preset = preset_name_value.as_str() .ok_or_else(|| format!("markdown template helper expects a string as preset name. Got: {preset_name_value}"))?; (value, preset) } _ => return Err("markdown template helper expects one or two arguments".to_string()), }; let markdown_src = match markdown_src_value { JsonValue::String(s) => Cow::Borrowed(s), JsonValue::Array(arr) => Cow::Owned( arr.iter() .map(|v| v.as_str().unwrap_or_default()) .collect::>() .join("\n"), ), JsonValue::Null => Cow::Owned(String::new()), other => Cow::Owned(other.to_string()), }; let options = self.get_preset_options(preset_name)?; markdown::to_html_with_options(&markdown_src, &options) .map(JsonValue::String) .map_err(|e| e.to_string()) } } fn buildinfo_helper(x: &JsonValue) -> anyhow::Result { match x { JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()), JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()), other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")), } } // rfc2822_date: take an ISO date and convert it to an RFC 2822 date fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result { let date: chrono::DateTime = match v { JsonValue::String(s) => { // we accept both dates with and without time chrono::DateTime::parse_from_rfc3339(s) .or_else(|_| { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset()) }) .with_context(|| format!("invalid date: {s}"))? } JsonValue::Number(n) => { chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0) .with_context(|| "invalid timestamp")? .into() } other => anyhow::bail!("expected a date, got {other:?}"), }; // format: Thu, 01 Jan 1970 00:00:00 +0000 Ok(date.format("%a, %d %b %Y %T %z").to_string().into()) } // Percent-encode a string fn url_encode_helper(v: &JsonValue) -> JsonValue { let as_str = match v { JsonValue::String(s) => s, other => &other.to_string(), }; percent_encoding::percent_encode(as_str.as_bytes(), percent_encoding::NON_ALPHANUMERIC) .to_string() .into() } // Percent-encode a string fn csv_escape_helper(v: &JsonValue, separator: &JsonValue) -> JsonValue { let as_str = match v { JsonValue::String(s) => s, other => &other.to_string(), }; let separator = separator.as_str().unwrap_or(","); if as_str.contains(separator) || as_str.contains('"') || as_str.contains('\n') { format!(r#""{}""#, as_str.replace('"', r#""""#)).into() } else { as_str.to_owned().into() } } fn with_each_block<'a, 'reg, 'rc>( rc: &'a mut handlebars::RenderContext<'reg, 'rc>, mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>, ) -> Result<(), RenderError> { let mut blks = Vec::new(); while let Some(mut top) = rc.block_mut().map(std::mem::take) { rc.pop_block(); action(&mut top, rc.block().is_none())?; blks.push(top); } while let Some(blk) = blks.pop() { rc.push_block(blk); } Ok(()) } pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents"; fn delay_helper<'reg, 'rc>( h: &handlebars::Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut handlebars::RenderContext<'reg, 'rc>, _out: &mut dyn handlebars::Output, ) -> handlebars::HelperResult { let inner = h .template() .ok_or(RenderErrorReason::BlockContentRequired)?; let mut str_out = handlebars::StringOutput::new(); inner.render(r, ctx, rc, &mut str_out)?; let mut delayed_render = str_out.into_string()?; with_each_block(rc, |block, is_last| { if is_last { let old_delayed_render = block .get_local_var(DELAYED_CONTENTS) .and_then(JsonValue::as_str) .unwrap_or_default(); delayed_render += old_delayed_render; let contents = JsonValue::String(std::mem::take(&mut delayed_render)); block.set_local_var(DELAYED_CONTENTS, contents); } Ok(()) })?; Ok(()) } fn flush_delayed_helper<'reg, 'rc>( _h: &handlebars::Helper<'rc>, _r: &'reg Handlebars<'reg>, _ctx: &'rc Context, rc: &mut handlebars::RenderContext<'reg, 'rc>, writer: &mut dyn handlebars::Output, ) -> handlebars::HelperResult { with_each_block(rc, |block_context, _last| { let delayed = block_context .get_local_var(DELAYED_CONTENTS) .and_then(JsonValue::as_str) .filter(|s| !s.is_empty()); if let Some(contents) = delayed { writer.write(contents)?; block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null); } Ok(()) }) } fn sum_helper<'reg, 'rc>( helper: &handlebars::Helper<'rc>, _r: &'reg Handlebars<'reg>, _ctx: &'rc Context, _rc: &mut handlebars::RenderContext<'reg, 'rc>, writer: &mut dyn handlebars::Output, ) -> handlebars::HelperResult { let mut sum = 0f64; for v in helper.params() { sum += v .value() .as_f64() .ok_or(RenderErrorReason::InvalidParamType("number"))?; } write!(writer, "{sum}")?; Ok(()) } /// Compare two values loosely, i.e. treat all values as strings. (42 == "42") fn loose_eq_helper(a: &JsonValue, b: &JsonValue) -> JsonValue { match (a, b) { (JsonValue::String(a), JsonValue::String(b)) => a == b, (JsonValue::String(a), non_str) => a == &non_str.to_string(), (non_str, JsonValue::String(b)) => &non_str.to_string() == b, (a, b) => a == b, } .into() } /// Helper that returns the first argument with the given truthiness, or the last argument if none have it. /// Equivalent to a && b && c && ... if the truthiness is false, /// or a || b || c || ... if the truthiness is true. pub struct HelperCheckTruthy(bool); impl CanHelp for HelperCheckTruthy { fn call(&self, args: &[PathAndJson]) -> Result { for arg in args { if arg.value().is_truthy(false) == self.0 { return Ok(arg.value().clone()); } } if let Some(last) = args.last() { Ok(last.value().clone()) } else { Err("expected at least one argument".to_string()) } } } trait CanHelp: Send + Sync + 'static { fn call(&self, v: &[PathAndJson]) -> Result; } impl CanHelp for H0 { fn call(&self, args: &[PathAndJson]) -> Result { match args { [] => Ok(self()), _ => Err("expected no arguments".to_string()), } } } impl CanHelp for H { fn call(&self, args: &[PathAndJson]) -> Result { match args { [v] => Ok(self(v.value())), _ => Err("expected one argument".to_string()), } } } impl CanHelp for EH { fn call(&self, args: &[PathAndJson]) -> Result { match args { [v] => self(v.value()).map_err(|e| e.to_string()), _ => Err("expected one argument".to_string()), } } } impl CanHelp for HH { fn call(&self, args: &[PathAndJson]) -> Result { match args { [a, b] => Ok(self(a.value(), b.value())), _ => Err("expected two arguments".to_string()), } } } impl CanHelp for HHH { fn call(&self, args: &[PathAndJson]) -> Result { match args { [a, b, c] => Ok(self(a.value(), b.value(), c.value())), _ => Err("expected three arguments".to_string()), } } } struct JFun { name: &'static str, fun: F, } impl handlebars::HelperDef for JFun { fn call_inner<'reg: 'rc, 'rc>( &self, helper: &handlebars::Helper<'rc>, _r: &'reg Handlebars<'reg>, _: &'rc Context, _rc: &mut handlebars::RenderContext<'reg, 'rc>, ) -> Result, RenderError> { let result = self .fun .call(helper.params().as_slice()) .map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?; Ok(ScopedJson::Derived(result)) } } fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) { h.register_helper(name, Box::new(JFun { name, fun })); } fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValue) -> JsonValue { let text_str = match text { JsonValue::String(s) => s, other => &other.to_string(), }; let original_str = match original { JsonValue::String(s) => s, other => &other.to_string(), }; let replacement_str = match replacement { JsonValue::String(s) => s, other => &other.to_string(), }; text_str.replace(original_str, replacement_str).into() } #[cfg(test)] mod tests { use crate::template_helpers::{CanHelp, MarkdownHelper, rfc2822_date_helper}; use handlebars::{JsonValue, PathAndJson, ScopedJson}; use serde_json::Value; const CONTENT_KEY: &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: &str = "
"; const ESCAPED_UNSAFE_MARKUP: &str = "<table><tr><td>"; #[test] fn test_html_blocks_with_various_settings() { struct TestCase { name: &'static str, preset: Option, expected_output: Result<&'static str, String>, } let helper = MarkdownHelper::default(); let content = contents(); let test_cases = [ TestCase { name: "default settings", preset: Some(Value::String("default".to_string())), expected_output: Ok(ESCAPED_UNSAFE_MARKUP), }, TestCase { name: "allow_unsafe preset", preset: Some(Value::String("allow_unsafe".to_string())), expected_output: Ok(UNSAFE_MARKUP), }, TestCase { name: "undefined allow_unsafe", preset: Some(Value::Null), expected_output: Err( "markdown template helper expects a string as preset name. Got: null" .to_string(), ), }, TestCase { name: "allow_unsafe is false", preset: Some(Value::Bool(false)), expected_output: Err( "markdown template helper expects a string as preset name. Got: false" .to_string(), ), }, ]; for case in test_cases { let args = match case.preset { None => &as_args(&content)[..], Some(ref preset) => &as_args_with_unsafe(&content, preset)[..], }; match helper.call(args) { Ok(actual) => assert_eq!( case.expected_output.unwrap(), actual.as_str().unwrap(), "Failed on case: {}", case.name ), Err(e) => assert_eq!( case.expected_output.unwrap_err(), e, "Failed on case: {}", case.name ), } } } 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("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()]) } }