fix #6143: notify: allow overriding notification templates

Previously, notification templates could be modified by the user, but
these were overwritten again with installing newer package versions of
pve-manager and proxmox-backup.

Now override templates can be created cluster-wide in the path
“/etc/{pve,proxmox-backup}/notification-templates/{namespace}”, which
are used with priority. The folder structure has to be created and
populated manually (e.g. /etc/pve/notification-templates/default).

If override templates are not existing or their rendering fails, the
vendor templates in
"/usr/share/{pve-manager,proxmox-backup}/templates/default/" are used.

Sequence: [override html -> vendor html ->] override txt -> vendor txt

An error is only returned if none of the template candidates could be
used. Using an override template gets not logged.

Signed-off-by: Alexander Zeidler <a.zeidler@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
Tested-by: Lukas Wagner <l.wagner@proxmox.com>
Link: https://lore.proxmox.com/20250321133341.151340-1-a.zeidler@proxmox.com
This commit is contained in:
Alexander Zeidler
2025-03-21 14:33:41 +01:00
committed by Thomas Lamprecht
parent 0afeba4b67
commit 684ffacdf9
5 changed files with 128 additions and 49 deletions

View File

@ -1,6 +1,7 @@
use std::fmt::Debug; use std::fmt::Debug;
use std::sync::Mutex; use std::sync::Mutex;
use crate::renderer::TemplateSource;
use crate::Error; use crate::Error;
#[cfg(any(feature = "pve-context", feature = "pbs-context"))] #[cfg(any(feature = "pve-context", feature = "pbs-context"))]
@ -24,11 +25,12 @@ pub trait Context: Send + Sync + Debug {
fn http_proxy_config(&self) -> Option<String>; fn http_proxy_config(&self) -> Option<String>;
/// Return default config for built-in targets/matchers. /// Return default config for built-in targets/matchers.
fn default_config(&self) -> &'static str; fn default_config(&self) -> &'static str;
/// Lookup a template in a certain (optional) namespace /// Return the path of `filename` from `source` and a certain (optional) `namespace`
fn lookup_template( fn lookup_template(
&self, &self,
filename: &str, filename: &str,
namespace: Option<&str>, namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error>; ) -> Result<Option<String>, Error>;
} }

View File

@ -7,6 +7,7 @@ use proxmox_schema::{ObjectSchema, Schema, StringSchema};
use proxmox_section_config::{SectionConfig, SectionConfigPlugin}; use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
use crate::context::{common, Context}; use crate::context::{common, Context};
use crate::renderer::TemplateSource;
use crate::Error; use crate::Error;
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg"; const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
@ -109,8 +110,14 @@ impl Context for PBSContext {
&self, &self,
filename: &str, filename: &str,
namespace: Option<&str>, namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error> { ) -> Result<Option<String>, Error> {
let path = Path::new("/usr/share/proxmox-backup/templates") let path = match source {
TemplateSource::Vendor => "/usr/share/proxmox-backup/templates",
TemplateSource::Override => "/etc/proxmox-backup/notification-templates",
};
let path = Path::new(&path)
.join(namespace.unwrap_or("default")) .join(namespace.unwrap_or("default"))
.join(filename); .join(filename);

View File

@ -1,4 +1,5 @@
use crate::context::{common, Context}; use crate::context::{common, Context};
use crate::renderer::TemplateSource;
use crate::Error; use crate::Error;
use std::path::Path; use std::path::Path;
@ -58,10 +59,17 @@ impl Context for PVEContext {
&self, &self,
filename: &str, filename: &str,
namespace: Option<&str>, namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error> { ) -> Result<Option<String>, Error> {
let path = Path::new("/usr/share/pve-manager/templates") let path = match source {
TemplateSource::Vendor => "/usr/share/pve-manager/templates",
TemplateSource::Override => "/etc/pve/notification-templates",
};
let path = Path::new(&path)
.join(namespace.unwrap_or("default")) .join(namespace.unwrap_or("default"))
.join(filename); .join(filename);
let template_string = proxmox_sys::fs::file_read_optional_string(path) let template_string = proxmox_sys::fs::file_read_optional_string(path)
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?; .map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
Ok(template_string) Ok(template_string)

View File

@ -1,4 +1,5 @@
use crate::context::Context; use crate::context::Context;
use crate::renderer::TemplateSource;
use crate::Error; use crate::Error;
#[derive(Debug)] #[derive(Debug)]
@ -29,6 +30,7 @@ impl Context for TestContext {
&self, &self,
_filename: &str, _filename: &str,
_namespace: Option<&str>, _namespace: Option<&str>,
_source: TemplateSource,
) -> Result<Option<String>, Error> { ) -> Result<Option<String>, Error> {
Ok(Some(String::new())) Ok(Some(String::new()))
} }

View File

@ -1,6 +1,6 @@
//! Module for rendering notification templates. //! Module for rendering notification templates.
use std::time::Duration; use std::{fmt::Display, time::Duration};
use handlebars::{ use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
@ -190,11 +190,29 @@ impl ValueRenderFunction {
} }
} }
/// Available template types /// Choose between the provided `vendor` template or its by the user optionally created `override`
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub enum TemplateSource {
Vendor,
Override,
}
impl Display for TemplateSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TemplateSource::Vendor => f.write_str("vendor"),
TemplateSource::Override => f.write_str("override"),
}
}
}
/// Available template types
#[derive(Copy, Clone, PartialEq)]
pub enum TemplateType { pub enum TemplateType {
/// HTML body template /// HTML body template
HtmlBody, HtmlBody,
/// Fallback HTML body, based on the `PlaintextBody` template
HtmlBodyFromPlaintext,
/// Plaintext body template /// Plaintext body template
PlaintextBody, PlaintextBody,
/// Subject template /// Subject template
@ -205,15 +223,25 @@ impl TemplateType {
fn file_suffix(&self) -> &'static str { fn file_suffix(&self) -> &'static str {
match self { match self {
TemplateType::HtmlBody => "body.html.hbs", TemplateType::HtmlBody => "body.html.hbs",
TemplateType::HtmlBodyFromPlaintext => "body.txt.hbs",
TemplateType::PlaintextBody => "body.txt.hbs", TemplateType::PlaintextBody => "body.txt.hbs",
TemplateType::Subject => "subject.txt.hbs", TemplateType::Subject => "subject.txt.hbs",
} }
} }
fn postprocess(&self, mut rendered: String) -> String { fn postprocess(&self, mut rendered: String) -> String {
if let Self::Subject = self { match self {
TemplateType::HtmlBodyFromPlaintext => {
rendered = format!(
"<html><body><pre>{}</pre></body></html>",
handlebars::html_escape(&rendered)
)
}
TemplateType::Subject => {
rendered = rendered.replace('\n', " "); rendered = rendered.replace('\n', " ");
} }
_ => {}
}
rendered rendered
} }
@ -221,6 +249,7 @@ impl TemplateType {
fn block_render_fns(&self) -> BlockRenderFunctions { fn block_render_fns(&self) -> BlockRenderFunctions {
match self { match self {
TemplateType::HtmlBody => html::block_render_functions(), TemplateType::HtmlBody => html::block_render_functions(),
TemplateType::HtmlBodyFromPlaintext => plaintext::block_render_functions(),
TemplateType::Subject => plaintext::block_render_functions(), TemplateType::Subject => plaintext::block_render_functions(),
TemplateType::PlaintextBody => plaintext::block_render_functions(), TemplateType::PlaintextBody => plaintext::block_render_functions(),
} }
@ -231,6 +260,7 @@ impl TemplateType {
TemplateType::PlaintextBody => handlebars::no_escape, TemplateType::PlaintextBody => handlebars::no_escape,
TemplateType::Subject => handlebars::no_escape, TemplateType::Subject => handlebars::no_escape,
TemplateType::HtmlBody => handlebars::html_escape, TemplateType::HtmlBody => handlebars::html_escape,
TemplateType::HtmlBodyFromPlaintext => handlebars::no_escape,
} }
} }
} }
@ -250,10 +280,14 @@ impl BlockRenderFunctions {
} }
fn render_template_impl( fn render_template_impl(
template: &str,
data: &Value, data: &Value,
renderer: TemplateType, renderer: TemplateType,
) -> Result<String, Error> { filename: &str,
source: TemplateSource,
) -> Result<Option<String>, Error> {
let template_string = context::context().lookup_template(&filename, None, source)?;
if let Some(template_string) = template_string {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(renderer.escape_fn()); handlebars.register_escape_fn(renderer.escape_fn());
@ -268,52 +302,78 @@ fn render_template_impl(
); );
let rendered_template = handlebars let rendered_template = handlebars
.render_template(template, data) .render_template(&template_string, data)
.map_err(|err| Error::RenderError(err.into()))?; .map_err(|err| Error::RenderError(err.into()))?;
Ok(rendered_template) let rendered_template = renderer.postprocess(rendered_template);
Ok(Some(rendered_template))
} else {
Ok(None)
}
} }
/// Render a template string. /// Render a template string.
/// ///
/// The output format can be chosen via the `renderer` parameter (see [TemplateType] /// The output format is chosen via the `ty` parameter (see [TemplateType] for
/// for available options). /// available options). If an override template is found and renderable, it is
/// used instead of the vendor one. If the [TemplateType] is `HtmlBody` but no
/// HTML template is found or renderable, it falls back to use a plaintext
/// template encapsulated in a pre-formatted HTML block (<pre>).
pub fn render_template( pub fn render_template(
mut ty: TemplateType, mut ty: TemplateType,
template: &str, template: &str,
data: &Value, data: &Value,
) -> Result<String, Error> { ) -> Result<String, Error> {
let mut source = TemplateSource::Override;
loop {
let filename = format!("{template}-{suffix}", suffix = ty.file_suffix()); let filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
let result = render_template_impl(data, ty, &filename, source);
let template_string = context::context().lookup_template(&filename, None)?; match result {
Ok(Some(s)) => {
return Ok(s);
}
Ok(None) => {}
Err(err) => {
tracing::error!("failed to render {source} template '{filename}': {err}");
}
}
let (template_string, fallback) = match (template_string, ty) { match (ty, source) {
(None, TemplateType::HtmlBody) => {
ty = TemplateType::PlaintextBody;
let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
( (
context::context().lookup_template(&plaintext_filename, None)?, TemplateType::HtmlBody
true, | TemplateType::HtmlBodyFromPlaintext
) | TemplateType::PlaintextBody
| TemplateType::Subject,
TemplateSource::Override,
) => {
// Override template not found or renderable, try the vendor one instead
source = TemplateSource::Vendor;
}
(TemplateType::HtmlBody, TemplateSource::Vendor) => {
// Override and vendor HTML templates not found or renderable,
// try next the override plaintext as fallback
ty = TemplateType::HtmlBodyFromPlaintext;
source = TemplateSource::Override;
}
(
TemplateType::HtmlBodyFromPlaintext
| TemplateType::PlaintextBody
| TemplateType::Subject,
TemplateSource::Vendor,
) => {
// Return error, no suitable templates found or renderable
break;
}
} }
(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) Err(Error::Generic(
"failed to render notification template, all template candidates are erroneous or missing"
.into(),
))
} }
#[cfg(test)] #[cfg(test)]