use std::collections::HashMap;
use std::sync::LazyLock;
use axum::response::Html;
use crate::webadmin::pages::error::PageError;
macro_rules! embed {
($name:literal) => {
($name, include_str!(concat!("../templates/", $name)))
};
}
static EMBEDDED_TEMPLATES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
HashMap::from([
embed!("layout.html"),
embed!("login.html"),
embed!("mfa/challenge.html"),
embed!("index.html"),
embed!("audit/list.html"),
embed!("audit/_table.html"),
embed!("audit/detail.html"),
embed!("audit/_card.html"),
embed!("mfa/_setup.html"),
embed!("mfa/_codes.html"),
embed!("mfa/enrolled.html"),
embed!("account/index.html"),
embed!("account/_mfa.html"),
embed!("account/_card.html"),
embed!("account/_enrol.html"),
embed!("account/_codes.html"),
embed!("partials/_flash.html"),
embed!("partials/_pager.html"),
embed!("profiles/list.html"),
embed!("profiles/_table.html"),
embed!("accounts/list.html"),
embed!("accounts/_table.html"),
embed!("accounts/detail.html"),
embed!("accounts/_card.html"),
embed!("orders/list.html"),
embed!("orders/_table.html"),
embed!("orders/detail.html"),
embed!("orders/_card.html"),
embed!("eab/list.html"),
embed!("eab/_table.html"),
embed!("eab/detail.html"),
embed!("eab/_card.html"),
embed!("eab/_created.html"),
embed!("nonces/index.html"),
embed!("nonces/_panel.html"),
])
});
#[must_use]
pub(crate) fn template_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = EMBEDDED_TEMPLATES.keys().copied().collect();
names.sort_unstable();
names
}
#[must_use]
pub(crate) fn build_environment(template_dir: &str) -> minijinja::Environment<'static> {
crate::templating::loader_env(template_dir, &EMBEDDED_TEMPLATES)
}
pub(crate) fn render(
env: &minijinja::Environment<'static>,
name: &str,
context: minijinja::Value,
) -> Result<Html<String>, PageError> {
let template = env.get_template(name).map_err(|error| {
tracing::error!(event = "admin_template_missing", outcome = "failure", template = name, error = %error);
PageError::internal()
})?;
let body = template.render(context).map_err(|error| {
tracing::error!(event = "admin_template_render_failed", outcome = "failure", template = name, error = %error);
PageError::internal()
})?;
Ok(Html(body))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempDir;
#[test]
fn every_embedded_template_compiles() {
let env = build_environment("");
for name in template_names() {
assert!(
env.get_template(name).is_ok(),
"`{name}` must compile: it is what `check_config` refuses to start without"
);
}
}
#[test]
fn auto_escaping_is_on_for_pages_and_off_for_notify() {
let mut env = minijinja::Environment::new();
env.add_template("page.html", "{{ value }}").unwrap();
env.add_template("mail.body.j2", "{{ value }}").unwrap();
let hostile = minijinja::context! { value => "<script>alert(1)</script>" };
let page = env
.get_template("page.html")
.unwrap()
.render(hostile.clone())
.unwrap();
assert!(!page.contains("<script>"));
assert_eq!(page, "<script>alert(1)</script>");
let mail = env
.get_template("mail.body.j2")
.unwrap()
.render(hostile)
.unwrap();
assert_eq!(mail, "<script>alert(1)</script>");
}
#[test]
fn a_template_dir_file_wins_over_the_embedded_default() {
let dir = TempDir::new("admin-templates");
dir.write("index.html", "overridden");
let env = build_environment(dir.path().to_str().unwrap());
let rendered = env
.get_template("index.html")
.unwrap()
.render(minijinja::context! {})
.unwrap();
assert_eq!(rendered, "overridden");
assert!(env.get_template("layout.html").is_ok());
}
#[test]
fn an_empty_template_dir_touches_no_disk() {
let env = build_environment("");
assert!(env.get_template("layout.html").is_ok());
assert!(env.get_template("no-such-template.html").is_err());
}
#[test]
fn render_reports_a_missing_template_as_internal() {
let env = build_environment("");
let error = render(&env, "no-such-template.html", minijinja::context! {}).unwrap_err();
assert_eq!(
error.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn render_produces_the_login_page() {
let env = build_environment("");
let Html(body) = render(&env, "login.html", minijinja::context! {}).unwrap();
assert!(body.starts_with("<!doctype html>"));
assert!(body.contains("name=\"password\""));
assert!(!body.contains("htmx.min.js"));
}
}