Skip to main content

vynil_core/
hbs.rs

1//! Handlebars templating engine.
2//!
3//! `HandleBars` wraps [`handlebars::Handlebars`] pre-registered with a set of generic helpers
4//! (base64/url/crc32, plus the `handlebars_misc_helpers` collection and the vendored JSON/JMESPath
5//! helpers). Feature-gated helpers (`argon_hash`, `gen_password`, …) are
6//! only available when the corresponding Cargo feature is enabled.
7//!
8//! Use [`HandleBars::new`] then [`HandleBars::engine_mut`] to add application-specific helpers.
9
10#[cfg(feature = "crypto")] use crate::hashes::Argon;
11use crate::{Error, Result, hbs_json};
12#[cfg(feature = "rhai")] use crate::{RhaiRes, rhai_err};
13use base64::{Engine as _, engine::general_purpose::STANDARD};
14use handlebars::{Handlebars, handlebars_helper};
15use handlebars_misc_helpers::new_hbs;
16use regex::Regex;
17use serde_json::Value;
18use std::{fs, path::PathBuf};
19use tracing::*;
20use url::form_urlencoded;
21
22/// Generic helpers available in core's HandleBars (no vynil context dependency).
23pub const CORE_HBS_HELPERS: &[&str] = &[
24    // Handlebars built-ins
25    "if",
26    "unless",
27    "each",
28    "with",
29    "lookup",
30    "raw",
31    "log",
32    "inline",
33    "eq",
34    "ne",
35    "gt",
36    "gte",
37    "lt",
38    "lte",
39    "and",
40    "or",
41    "not",
42    "len",
43    // handlebars string_helpers feature (case helpers)
44    "lowerCamelCase",
45    "upperCamelCase",
46    "snakeCase",
47    "kebabCase",
48    "shoutySnakeCase",
49    "shoutyKebabCase",
50    "titleCase",
51    "trainCase",
52    // handlebars_misc_helpers — file (unconditional)
53    "read_to_str",
54    // handlebars_misc_helpers — path (unconditional)
55    "parent",
56    "file_name",
57    "extension",
58    "canonicalize",
59    // handlebars_misc_helpers — env (unconditional)
60    "env_var",
61    // handlebars_misc_helpers — string feature
62    "to_lower_case",
63    "to_upper_case",
64    "trim",
65    "trim_start",
66    "trim_end",
67    "replace",
68    "quote",
69    "unquote",
70    "first_non_empty",
71    // vynil-core (vendored from handlebars_misc_helpers's json feature — see hbs_json.rs)
72    "json_to_str",
73    "str_to_json",
74    "from_json",
75    "to_json",
76    "json_query",
77    "json_str_query",
78    // handlebars_misc_helpers — jsonnet feature
79    "jsonnet",
80    // handlebars_misc_helpers — regex feature
81    "regex_captures",
82    "regex_is_match",
83    // handlebars_misc_helpers — uuid feature
84    "uuid_new_v4",
85    "uuid_new_v7",
86    // vynil core helpers
87    "base64_encode",
88    "base64_decode",
89    "url_encode",
90    "to_decimal",
91    "header_basic",
92    #[cfg(feature = "crypto")]
93    "argon_hash",
94    #[cfg(feature = "crypto")]
95    "bcrypt_hash",
96    "crc32_hash",
97    #[cfg(feature = "password")]
98    "gen_password",
99    #[cfg(feature = "password")]
100    "gen_password_alphanum",
101    #[cfg(feature = "crypto")]
102    "gen_private_key",
103    "concat",
104];
105
106handlebars_helper!(base64_decode: |arg:Value| String::from_utf8(STANDARD.decode(arg.as_str().unwrap_or_else(|| {
107    warn!("handlebars::base64_decode received a non-string parameter: {:?}",arg);
108    ""
109})).unwrap_or_else(|e| {
110    warn!("handlebars::base64_decode failed to decode with: {e:?}");
111    vec![]
112})).unwrap_or_else(|e| {
113    warn!("handlebars::base64_decode failed to convert to string with: {e:?}");
114    String::new()
115}));
116handlebars_helper!(base64_encode: |arg:Value| STANDARD.encode(arg.as_str().unwrap_or_else(|| {
117    warn!("handlebars::base64_encode received a non-string parameter: {:?}",arg);
118    ""
119})));
120handlebars_helper!(url_encode: |arg:Value| form_urlencoded::byte_serialize(arg.as_str().unwrap_or_else(|| {
121    warn!("handlebars::url_encode received a non-string parameter: {:?}",arg);
122    ""
123}).as_bytes()).collect::<String>());
124handlebars_helper!(to_decimal: |arg:Value| format!("{}", u32::from_str_radix(arg.as_str().unwrap_or_else(|| {
125    warn!("handlebars::to_decimal received a non-string parameter: {:?}",arg);
126    ""
127}), 8).unwrap_or_else(|_| {
128    warn!("handlebars::to_decimal received a non-string parameter: {:?}",arg);
129    0
130})));
131handlebars_helper!(header_basic: |username:Value, password:Value| format!("Basic {}",STANDARD.encode(format!("{}:{}",username.as_str().unwrap_or_else(|| {
132    warn!("handlebars::header_basic received a non-string username: {:?}",username);
133    ""
134}),password.as_str().unwrap_or_else(|| {
135    warn!("handlebars::header_basic received a non-string password: {:?}",password);
136    ""
137})))));
138#[cfg(feature = "crypto")]
139handlebars_helper!(argon_hash: |password:Value| Argon::new().hash(password.as_str().unwrap_or_else(|| {
140    warn!("handlebars::argon_hash received a non-string password: {:?}",password);
141    ""
142}).to_string()).unwrap_or_else(|e| {
143    warn!("handlebars::argon_hash failed to convert to string with: {e:?}");
144    String::new()
145}));
146#[cfg(feature = "crypto")]
147handlebars_helper!(bcrypt_hash: |password:Value| crate::hashes::bcrypt_hash(password.as_str().unwrap_or_else(|| {
148    warn!("handlebars::bcrypt_hash received a non-string password: {:?}",password);
149    ""
150}).to_string()).unwrap_or_else(|e| {
151    warn!("handlebars::bcrypt_hash failed to convert to string with: {e:?}");
152    String::new()
153}));
154handlebars_helper!(crc32_hash: |password:Value| crate::hashes::crc32_hash(password.as_str().unwrap_or_else(|| {
155    warn!("handlebars::crc32_hash received a non-string password: {:?}",password);
156    ""
157}).to_string()));
158#[cfg(feature = "password")]
159handlebars_helper!(gen_password: |len:u32, {lower:u32=1, upper:u32=1, digits:u32=1, symbols:u32=1}| crate::password::generate(len as usize, lower as usize, upper as usize, digits as usize, symbols as usize).unwrap_or_else(|e| {
160    warn!("handlebars::gen_password failed with: {e:?}");
161    String::new()
162}));
163#[cfg(feature = "password")]
164handlebars_helper!(gen_password_alphanum: |len:u32| crate::password::generate(len as usize, 1, 1, 1, 0).unwrap_or_else(|e| {
165    warn!("handlebars::gen_password_alphanum failed with: {e:?}");
166    String::new()
167}));
168#[cfg(feature = "crypto")]
169handlebars_helper!(gen_private_key: |algo:str, {bits:u32=4096}| crate::key::gen_private_key(algo, bits).unwrap_or_else(|e| {
170    warn!("handlebars::gen_private_key failed with: {e:?}");
171    String::new()
172}));
173handlebars_helper!(concat: |a: Value, b: Value| format!("{}{}", a.as_str().unwrap_or_else(|| {
174    warn!("handlebars::concat received a non-string parameter: {:?}", a);
175    ""
176}),b.as_str().unwrap_or_else(|| {
177    warn!("handlebars::concat received a non-string parameter: {:?}", b);
178    ""
179})));
180
181/// Handlebars wrapper with generic helpers pre-registered.
182///
183/// See [`CORE_HBS_HELPERS`] for the included helper names.
184#[derive(Clone, Debug)]
185pub struct HandleBars<'a> {
186    engine: Handlebars<'a>,
187}
188impl<'a> HandleBars<'a> {
189    /// Create a new engine with generic helpers registered.
190    #[must_use]
191    pub fn new() -> HandleBars<'static> {
192        let mut engine = new_hbs();
193        hbs_json::register(&mut engine);
194        engine.register_helper("concat", Box::new(concat));
195        engine.register_helper("to_decimal", Box::new(to_decimal));
196        engine.register_helper("base64_decode", Box::new(base64_decode));
197        engine.register_helper("base64_encode", Box::new(base64_encode));
198        engine.register_helper("header_basic", Box::new(header_basic));
199        #[cfg(feature = "crypto")]
200        {
201            engine.register_helper("argon_hash", Box::new(argon_hash));
202            engine.register_helper("bcrypt_hash", Box::new(bcrypt_hash));
203            engine.register_helper("gen_private_key", Box::new(gen_private_key));
204        }
205        engine.register_helper("url_encode", Box::new(url_encode));
206        #[cfg(feature = "password")]
207        engine.register_helper("gen_password", Box::new(gen_password));
208        #[cfg(feature = "password")]
209        engine.register_helper("gen_password_alphanum", Box::new(gen_password_alphanum));
210        engine.register_helper("crc32_hash", Box::new(crc32_hash));
211        HandleBars { engine }
212    }
213
214    /// Expose the inner [`Handlebars`] to register custom helpers or configuration.
215    #[must_use]
216    pub fn engine_mut(&mut self) -> &mut Handlebars<'a> {
217        &mut self.engine
218    }
219
220    /// Register a template string under `name`.
221    pub fn register_template(&mut self, name: &str, template: &str) -> Result<()> {
222        self.engine
223            .register_template_string(name, template)
224            .map_err(Error::HbsTemplateError)
225    }
226
227    #[cfg(feature = "rhai")]
228    pub fn rhai_register_template(&mut self, name: String, template: String) -> RhaiRes<()> {
229        self.register_template(name.as_str(), template.as_str())
230            .map_err(|e| format!("{e}").into())
231    }
232
233    /// Register every `*.rhai` file in `directory` as a Handlebars script helper
234    /// (requires `hbs-scripting` feature).
235    #[cfg(feature = "hbs-scripting")]
236    pub fn register_helper_dir(&mut self, directory: PathBuf) -> Result<()> {
237        if std::path::Path::new(&directory).is_dir() {
238            let re_rhai = Regex::new(r"\.rhai$").unwrap();
239            for file in fs::read_dir(directory).unwrap() {
240                let path = file.unwrap().path();
241                let filename = path.file_name().unwrap().to_str().unwrap();
242                if re_rhai.is_match(filename) {
243                    let name = filename[0..(filename.len() - 5)].to_string();
244                    self.engine
245                        .register_script_helper_file(&name, path)
246                        .map_err(|e| Error::Other(format!("{:?}", e)))?;
247                }
248            }
249            Ok(())
250        } else {
251            Ok(())
252        }
253    }
254
255    /// Rhai-facing wrapper for [`HandleBars::register_helper_dir`].
256    #[cfg(feature = "hbs-scripting")]
257    pub fn rhai_register_helper_dir(&mut self, directory: String) -> RhaiRes<()> {
258        self.register_helper_dir(PathBuf::from(directory))
259            .map_err(rhai_err)
260    }
261
262    /// Register every `*.hbs` file in `directory` as a partial/template.
263    pub fn register_partial_dir(&mut self, directory: PathBuf) -> Result<()> {
264        if std::path::Path::new(&directory).is_dir() {
265            let re_rhai = Regex::new(r"\.hbs$").unwrap();
266            for file in fs::read_dir(directory).unwrap() {
267                let path = file.unwrap().path();
268                let filename = path.file_name().unwrap().to_str().unwrap();
269                if re_rhai.is_match(filename) {
270                    let name = filename[0..(filename.len() - 4)].to_string();
271                    let tmpl = std::fs::read_to_string(path).map_err(Error::Stdio)?;
272                    tracing::debug!("registering {}", name);
273                    self.register_template(&name, &tmpl)?;
274                }
275            }
276            Ok(())
277        } else {
278            Ok(())
279        }
280    }
281
282    #[cfg(feature = "rhai")]
283    pub fn rhai_register_partial_dir(&mut self, directory: String) -> RhaiRes<()> {
284        self.register_partial_dir(PathBuf::from(directory))
285            .map_err(rhai_err)
286    }
287
288    /// Render an inline `template` string with `data`.
289    pub fn render(&mut self, template: &str, data: &serde_json::Value) -> Result<String> {
290        self.engine
291            .render_template(template, data)
292            .map_err(Error::HbsRenderError)
293    }
294
295    #[cfg(feature = "rhai")]
296    pub fn rhai_render(&mut self, template: String, data: rhai::Map) -> RhaiRes<String> {
297        let json_data: serde_json::Value =
298            serde_json::from_str(&serde_json::to_string(&data).map_err(|e| format!("{e}"))?)
299                .map_err(|e| format!("{e}"))?;
300        self.engine
301            .render_template(template.as_str(), &json_data)
302            .map_err(|e| format!("{e}").into())
303    }
304
305    /// Register `template` as `name` then render it with `data`.
306    pub fn render_named(&mut self, name: &str, template: &str, data: &serde_json::Value) -> Result<String> {
307        self.engine
308            .register_template_string(name, template)
309            .map_err(Error::HbsTemplateError)?;
310        self.engine.render(name, data).map_err(Error::HbsRenderError)
311    }
312
313    #[cfg(feature = "rhai")]
314    pub fn rhai_render_named(&mut self, name: String, template: String, data: rhai::Map) -> RhaiRes<String> {
315        let json_data: serde_json::Value =
316            serde_json::from_str(&serde_json::to_string(&data).map_err(|e| format!("{e}"))?)
317                .map_err(|e| format!("{e}"))?;
318        self.engine
319            .register_template_string(name.as_str(), template)
320            .map_err(Error::HbsTemplateError)
321            .map_err(rhai_err)?;
322        self.engine
323            .render(name.as_str(), &json_data)
324            .map_err(|e| format!("{e}").into())
325    }
326}