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!("expiring/list.html"),
embed!("expiring/_table.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!("account/_password.html"),
embed!("account/_contact.html"),
embed!("partials/_filter_meta.html"),
embed!("account/_password_card.html"),
embed!("account/_sessions.html"),
embed!("operators/list.html"),
embed!("operators/_table.html"),
embed!("operators/detail.html"),
embed!("operators/_card.html"),
embed!("partials/_flash.html"),
embed!("partials/_pager.html"),
embed!("partials/_sessions_table.html"),
embed!("profiles/list.html"),
embed!("profiles/filter.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!("jobs/list.html"),
embed!("jobs/_table.html"),
embed!("jobs/detail.html"),
embed!("jobs/_card.html"),
embed!("upstream_orders/list.html"),
embed!("upstream_orders/_table.html"),
embed!("upstream_orders/detail.html"),
embed!("upstream_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> {
let mut env = acme_proxy_core::templating::loader_env(template_dir, &EMBEDDED_TEMPLATES);
env.add_filter("ago", ago);
env
}
fn ago(value: minijinja::Value) -> String {
value
.as_str()
.map(|text| ago_at(text, time::OffsetDateTime::now_utc().unix_timestamp()))
.unwrap_or_default()
}
fn ago_at(value: &str, now: i64) -> String {
let Ok(then) =
time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
else {
return String::new();
};
let delta = now.saturating_sub(then.unix_timestamp());
let seconds = delta.saturating_abs();
let amount = match seconds {
0..60 => return "just now".to_string(),
60..3_600 => format!("{} min", seconds / 60),
3_600..86_400 => format!("{} h", seconds / 3_600),
_ => format!("{} d", seconds / 86_400),
};
if delta < 0 {
format!("in {amount}")
} else {
format!("{amount} ago")
}
}
#[cfg(test)]
mod ago_tests {
use super::ago_at;
const NOW: i64 = 1_800_000_000;
#[test]
fn a_past_timestamp_reads_as_how_long_ago() {
assert_eq!(ago_at("2027-01-15T07:59:30Z", NOW), "just now");
assert_eq!(ago_at("2027-01-15T07:55:00Z", NOW), "5 min ago");
assert_eq!(ago_at("2027-01-15T05:00:00Z", NOW), "3 h ago");
assert_eq!(ago_at("2027-01-10T08:00:00Z", NOW), "5 d ago");
}
#[test]
fn a_future_timestamp_reads_as_how_long_until() {
assert_eq!(ago_at("2027-01-15T10:00:00Z", NOW), "in 2 h");
assert_eq!(ago_at("2027-02-14T08:00:00Z", NOW), "in 30 d");
}
#[test]
fn anything_unparseable_renders_nothing() {
assert_eq!(ago_at("", NOW), "");
assert_eq!(ago_at("yesterday", NOW), "");
assert_eq!(super::ago(minijinja::Value::from(())), "");
assert_eq!(super::ago(minijinja::Value::from(42)), "");
}
}
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 acme_proxy_core::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 every_page_template_is_html_and_every_notify_template_is_not() {
for name in template_names() {
assert!(
name.ends_with(".html"),
"`{name}` is a page template, so it must be named `.html` — minijinja \
reads auto-escaping off the extension, and anything else renders \
client-supplied text unescaped"
);
}
for name in acme_proxy_jobs::notify::template_names() {
assert!(
name.ends_with(".j2"),
"`{name}` is a notify template, so it must be named `.j2` — an `.html` \
here would HTML-escape every message body the server sends"
);
}
}
#[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"));
}
}