mirror of
https://git.proxmox.com/git/proxmox
synced 2025-10-04 16:49:12 +00:00

Instead of passing the template strings for subject and body when constructing a notification, we pass only the name of a template. When rendering the template, the name of the template is used to find corresponding template files. For PVE, they are located at /usr/share/proxmox-ve/templates/default. The `default` part is the 'template namespace', which is a preparation for user-customizable and/or translatable notifications. Previously, the same template string was used to render HTML and plaintext notifications. This was achieved by providing some template helpers that 'abstract away' HTML/plaintext formatting. However, in hindsight this turned out to be pretty finicky. Since the current changes lay the foundations for user-customizable notification templates, I ripped these abstractions out. Now there are simply two templates, one for plaintext, one for HTML. Signed-off-by: Lukas Wagner <l.wagner@proxmox.com> Tested-by: Folke Gleumes <f.gleumes@proxmox.com> Reviewed-by: Fiona Ebner <f.ebner@proxmox.com>
312 lines
9.3 KiB
Rust
312 lines
9.3 KiB
Rust
//! Module for rendering notification templates.
|
|
|
|
use std::time::Duration;
|
|
|
|
use handlebars::{
|
|
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
|
|
RenderError as HandlebarsRenderError,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
use proxmox_human_byte::HumanByte;
|
|
use proxmox_time::TimeSpan;
|
|
|
|
use crate::{context, Error};
|
|
|
|
mod html;
|
|
mod plaintext;
|
|
mod table;
|
|
|
|
/// Convert a serde_json::Value to a String.
|
|
///
|
|
/// The main difference between this and simply calling Value::to_string is that
|
|
/// this will print strings without double quotes
|
|
fn value_to_string(value: &Value) -> String {
|
|
match value {
|
|
Value::String(s) => s.clone(),
|
|
v => v.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Render a `serde_json::Value` as a byte size with proper units (IEC, base 2).
|
|
/// Accepts `serde_json::Value::{Number,String}`.
|
|
///
|
|
/// Will return `None` if `val` does not contain a number/parseable string.
|
|
fn value_to_byte_size(val: &Value) -> Option<String> {
|
|
let size = match val {
|
|
Value::Number(n) => n.as_f64(),
|
|
Value::String(s) => s.parse().ok(),
|
|
_ => None,
|
|
}?;
|
|
|
|
Some(format!("{}", HumanByte::new_binary(size)))
|
|
}
|
|
|
|
/// Render a serde_json::Value as a duration.
|
|
/// The value is expected to contain the duration in seconds.
|
|
/// Accepts `serde_json::Value::{Number,String}`.
|
|
///
|
|
/// Will return `None` if `val` does not contain a number/parseable string.
|
|
fn value_to_duration(val: &Value) -> Option<String> {
|
|
let duration = match val {
|
|
Value::Number(n) => n.as_u64(),
|
|
Value::String(s) => s.parse().ok(),
|
|
_ => None,
|
|
}?;
|
|
let time_span = TimeSpan::from(Duration::from_secs(duration));
|
|
|
|
Some(format!("{time_span}"))
|
|
}
|
|
|
|
/// Render as serde_json::Value as a timestamp.
|
|
/// The value is expected to contain the timestamp as a unix epoch.
|
|
/// Accepts `serde_json::Value::{Number,String}`.
|
|
///
|
|
/// Will return `None` if `val` does not contain a number/parseable string.
|
|
fn value_to_timestamp(val: &Value) -> Option<String> {
|
|
let timestamp = match val {
|
|
Value::Number(n) => n.as_i64(),
|
|
Value::String(s) => s.parse().ok(),
|
|
_ => None,
|
|
}?;
|
|
proxmox_time::strftime_local("%F %H:%M:%S", timestamp).ok()
|
|
}
|
|
|
|
/// Available render functions for `serde_json::Values``
|
|
///
|
|
/// May be used as a handlebars helper, e.g.
|
|
/// ```text
|
|
/// {{human-bytes 1024}}
|
|
/// ```
|
|
///
|
|
/// Value renderer can also be used for rendering values in table columns:
|
|
/// ```text
|
|
/// let properties = json!({
|
|
/// "table": {
|
|
/// "schema": {
|
|
/// "columns": [
|
|
/// {
|
|
/// "label": "Size",
|
|
/// "id": "size",
|
|
/// "renderer": "human-bytes"
|
|
/// }
|
|
/// ],
|
|
/// },
|
|
/// "data" : [
|
|
/// {
|
|
/// "size": 1024 * 1024,
|
|
/// },
|
|
/// ]
|
|
/// }
|
|
/// });
|
|
/// ```
|
|
///
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum ValueRenderFunction {
|
|
HumanBytes,
|
|
Duration,
|
|
Timestamp,
|
|
}
|
|
|
|
impl ValueRenderFunction {
|
|
fn render(&self, value: &Value) -> String {
|
|
match self {
|
|
ValueRenderFunction::HumanBytes => value_to_byte_size(value),
|
|
ValueRenderFunction::Duration => value_to_duration(value),
|
|
ValueRenderFunction::Timestamp => value_to_timestamp(value),
|
|
}
|
|
.unwrap_or_else(|| {
|
|
log::error!("could not render value {value} with renderer {self:?}");
|
|
String::from("ERROR")
|
|
})
|
|
}
|
|
|
|
fn register_helpers(handlebars: &mut Handlebars) {
|
|
ValueRenderFunction::HumanBytes.register_handlebars_helper(handlebars);
|
|
ValueRenderFunction::Duration.register_handlebars_helper(handlebars);
|
|
ValueRenderFunction::Timestamp.register_handlebars_helper(handlebars);
|
|
}
|
|
|
|
fn register_handlebars_helper(&'static self, handlebars: &mut Handlebars) {
|
|
// Use serde to get own kebab-case representation that is later used
|
|
// to register the helper, e.g. HumanBytes -> human-bytes
|
|
let tag = serde_json::to_string(self)
|
|
.expect("serde failed to serialize ValueRenderFunction enum");
|
|
|
|
// But as it's a string value, the generated string is quoted,
|
|
// so remove leading/trailing double quotes
|
|
let tag = tag
|
|
.strip_prefix('\"')
|
|
.and_then(|t| t.strip_suffix('\"'))
|
|
.expect("serde serialized string representation was not contained in double quotes");
|
|
|
|
handlebars.register_helper(
|
|
tag,
|
|
Box::new(
|
|
|h: &Helper,
|
|
_r: &Handlebars,
|
|
_: &Context,
|
|
_rc: &mut RenderContext,
|
|
out: &mut dyn Output|
|
|
-> HelperResult {
|
|
let param = h
|
|
.param(0)
|
|
.ok_or(HandlebarsRenderError::new("parameter not found"))?;
|
|
|
|
let value = param.value();
|
|
out.write(&self.render(value))?;
|
|
|
|
Ok(())
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Available template types
|
|
#[derive(Copy, Clone)]
|
|
pub enum TemplateType {
|
|
/// HTML body template
|
|
HtmlBody,
|
|
/// Plaintext body template
|
|
PlaintextBody,
|
|
/// Plaintext body template
|
|
Subject,
|
|
}
|
|
|
|
impl TemplateType {
|
|
fn file_suffix(&self) -> &'static str {
|
|
match self {
|
|
TemplateType::HtmlBody => "body.html.hbs",
|
|
TemplateType::PlaintextBody => "body.txt.hbs",
|
|
TemplateType::Subject => "subject.txt.hbs",
|
|
}
|
|
}
|
|
|
|
fn postprocess(&self, mut rendered: String) -> String {
|
|
if let Self::Subject = self {
|
|
rendered = rendered.replace('\n', " ");
|
|
}
|
|
|
|
rendered
|
|
}
|
|
|
|
fn block_render_fns(&self) -> BlockRenderFunctions {
|
|
match self {
|
|
TemplateType::HtmlBody => html::block_render_functions(),
|
|
TemplateType::Subject => plaintext::block_render_functions(),
|
|
TemplateType::PlaintextBody => plaintext::block_render_functions(),
|
|
}
|
|
}
|
|
|
|
fn escape_fn(&self) -> fn(&str) -> String {
|
|
match self {
|
|
TemplateType::PlaintextBody => handlebars::no_escape,
|
|
TemplateType::Subject => handlebars::no_escape,
|
|
TemplateType::HtmlBody => handlebars::html_escape,
|
|
}
|
|
}
|
|
}
|
|
|
|
type HelperFn = dyn HelperDef + Send + Sync;
|
|
|
|
struct BlockRenderFunctions {
|
|
table: Box<HelperFn>,
|
|
object: Box<HelperFn>,
|
|
}
|
|
|
|
impl BlockRenderFunctions {
|
|
fn register_helpers(self, handlebars: &mut Handlebars) {
|
|
handlebars.register_helper("table", self.table);
|
|
handlebars.register_helper("object", self.object);
|
|
}
|
|
}
|
|
|
|
fn render_template_impl(
|
|
template: &str,
|
|
data: &Value,
|
|
renderer: TemplateType,
|
|
) -> Result<String, Error> {
|
|
let mut handlebars = Handlebars::new();
|
|
handlebars.register_escape_fn(renderer.escape_fn());
|
|
|
|
let block_render_fns = renderer.block_render_fns();
|
|
block_render_fns.register_helpers(&mut handlebars);
|
|
|
|
ValueRenderFunction::register_helpers(&mut handlebars);
|
|
|
|
let rendered_template = handlebars
|
|
.render_template(template, data)
|
|
.map_err(|err| Error::RenderError(err.into()))?;
|
|
|
|
Ok(rendered_template)
|
|
}
|
|
|
|
/// Render a template string.
|
|
///
|
|
/// The output format can be chosen via the `renderer` parameter (see [TemplateType]
|
|
/// for available options).
|
|
pub fn render_template(
|
|
mut ty: TemplateType,
|
|
template: &str,
|
|
data: &Value,
|
|
) -> Result<String, Error> {
|
|
let filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
|
|
|
|
let template_string = context::context().lookup_template(&filename, None)?;
|
|
|
|
let (template_string, fallback) = match (template_string, ty) {
|
|
(None, TemplateType::HtmlBody) => {
|
|
ty = TemplateType::PlaintextBody;
|
|
let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
|
|
log::info!("html template '{filename}' not found, falling back to plain text template '{plaintext_filename}'");
|
|
(
|
|
context::context().lookup_template(&plaintext_filename, None)?,
|
|
true,
|
|
)
|
|
}
|
|
(template_string, _) => (template_string, false),
|
|
};
|
|
|
|
let template_string = template_string.ok_or(Error::Generic(format!(
|
|
"could not load template '{template}'"
|
|
)))?;
|
|
|
|
let mut rendered = render_template_impl(&template_string, data, ty)?;
|
|
rendered = ty.postprocess(rendered);
|
|
|
|
if fallback {
|
|
rendered = format!(
|
|
"<html><body><pre>{}</pre></body></html>",
|
|
handlebars::html_escape(&rendered)
|
|
);
|
|
}
|
|
|
|
Ok(rendered)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_helpers() {
|
|
assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string()));
|
|
assert_eq!(
|
|
value_to_byte_size(&json!("1024")),
|
|
Some("1 KiB".to_string())
|
|
);
|
|
|
|
assert_eq!(value_to_duration(&json!(60)), Some("1min ".to_string()));
|
|
assert_eq!(value_to_duration(&json!("60")), Some("1min ".to_string()));
|
|
|
|
// The rendered value is in localtime, so we only check if the result is `Some`...
|
|
// ... otherwise the test will break in another timezone :S
|
|
assert!(value_to_timestamp(&json!(60)).is_some());
|
|
assert!(value_to_timestamp(&json!("60")).is_some());
|
|
}
|
|
}
|