use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use minijinja::{Environment, ErrorKind};
use serde::Serialize;
use crate::error::{Error, Result};
pub struct Templates {
env: Mutex<Environment<'static>>,
}
impl Templates {
pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
let disk_root = project_templates_dir;
if let Some(root) = disk_root.as_deref() {
for v in validate_overrides(root) {
match v {
OverrideValidation::Loaded { name, bytes } => {
log::info!(
"templates: project override loaded for `{name}` ({bytes} bytes)"
);
}
OverrideValidation::Suspicious { name, bytes } => {
log::warn!(
"templates: project override for `{name}` looks incomplete \
({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
`<html>` tag) — the admin UI may render incorrectly. Either \
copy the framework default in full or remove the override."
);
}
OverrideValidation::Unreadable { name, error } => {
log::warn!(
"templates: project override `{name}` exists but cannot be read: {error}"
);
}
OverrideValidation::OrphanAdminFile { path } => {
log::warn!(
"templates: `{path}` is in the admin namespace but does not \
override any embedded template (typo? framework default \
will be served unchanged). Project-specific admin pages \
belong outside `templates/admin/`."
);
}
}
}
}
let mut env = Environment::new();
env.set_loader(move |name| load_template(disk_root.as_deref(), name));
env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
let class: String = kwargs.get("class").unwrap_or_default();
kwargs.assert_all_used().ok();
minijinja::value::Value::from_safe_string(crate::admin::icons::render_inline(
name, &class,
))
});
Ok(Arc::new(Self {
env: Mutex::new(env),
}))
}
pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
let mut env = self
.env
.lock()
.map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
env.clear_templates();
let tmpl = env
.get_template(name)
.map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
tmpl.render(ctx).map_err(|e| {
log::error!("template render failed for {name}: {e:?}");
Error::Internal(format!("render {name}: {e}"))
})
}
pub fn render_for_model<S: Serialize>(
&self,
model: &str,
name: &str,
ctx: &S,
) -> Result<String> {
let page = name.strip_prefix("admin/").unwrap_or(name);
let per_model = format!("admin/{model}/{page}");
let mut env = self
.env
.lock()
.map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
env.clear_templates();
if let Ok(tmpl) = env.get_template(&per_model) {
return tmpl
.render(ctx)
.map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
}
let tmpl = env
.get_template(name)
.map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
tmpl.render(ctx)
.map_err(|e| Error::Internal(format!("render {name}: {e}")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OverrideValidation {
Loaded { name: &'static str, bytes: usize },
Suspicious { name: &'static str, bytes: usize },
Unreadable { name: &'static str, error: String },
OrphanAdminFile { path: String },
}
pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
let mut results = Vec::new();
for (name, _embedded) in EMBEDDED_TEMPLATES {
let path = disk_root.join(name);
if !path.is_file() {
continue;
}
match std::fs::read_to_string(&path) {
Ok(body) => {
let bytes = body.len();
let has_structure = body.contains("{% extends")
|| body.contains("{% block")
|| body.contains("<html");
if has_structure {
results.push(OverrideValidation::Loaded { name, bytes });
} else {
results.push(OverrideValidation::Suspicious { name, bytes });
}
}
Err(e) => {
results.push(OverrideValidation::Unreadable {
name,
error: e.to_string(),
});
}
}
}
let admin_dir = disk_root.join("admin");
if admin_dir.is_dir() {
let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
.iter()
.filter_map(|(n, _)| n.strip_prefix("admin/"))
.collect();
if let Ok(entries) = std::fs::read_dir(&admin_dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("html"))
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.file_name());
for entry in files {
let file_name = entry.file_name();
let Some(stem_html) = file_name.to_str() else {
continue;
};
if known.contains(stem_html) {
continue;
}
results.push(OverrideValidation::OrphanAdminFile {
path: format!("admin/{stem_html}"),
});
}
}
}
results
}
fn load_template(
disk_root: Option<&std::path::Path>,
name: &str,
) -> std::result::Result<Option<String>, minijinja::Error> {
if let Some(root) = disk_root {
let path = root.join(name);
if path.exists() {
return std::fs::read_to_string(&path).map(Some).map_err(|e| {
minijinja::Error::new(
ErrorKind::InvalidOperation,
format!("read template {}: {e}", path.display()),
)
});
}
}
Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
if *n == name {
Some((*b).to_string())
} else {
None
}
}))
}
pub fn embedded_template_names() -> Vec<&'static str> {
EMBEDDED_TEMPLATES.iter().map(|(n, _)| *n).collect()
}
pub fn embedded_template_source(name: &str) -> Option<&'static str> {
EMBEDDED_TEMPLATES
.iter()
.find_map(|(n, body)| if *n == name { Some(*body) } else { None })
}
const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
(
"admin/_base.html",
include_str!("../assets/templates/admin/_base.html"),
),
(
"admin/_topbar.html",
include_str!("../assets/templates/admin/_topbar.html"),
),
(
"admin/_sidebar.html",
include_str!("../assets/templates/admin/_sidebar.html"),
),
(
"admin/_theme.html",
include_str!("../assets/templates/admin/_theme.html"),
),
(
"admin/_row_actions.html",
include_str!("../assets/templates/admin/_row_actions.html"),
),
(
"admin/includes/_form_field.html",
include_str!("../assets/templates/admin/includes/_form_field.html"),
),
(
"admin/includes/_field_errors.html",
include_str!("../assets/templates/admin/includes/_field_errors.html"),
),
(
"admin/login.html",
include_str!("../assets/templates/admin/login.html"),
),
(
"admin/index.html",
include_str!("../assets/templates/admin/index.html"),
),
(
"admin/list.html",
include_str!("../assets/templates/admin/list.html"),
),
(
"admin/form.html",
include_str!("../assets/templates/admin/form.html"),
),
(
"admin/confirm_delete.html",
include_str!("../assets/templates/admin/confirm_delete.html"),
),
(
"admin/bulk_confirm_delete.html",
include_str!("../assets/templates/admin/bulk_confirm_delete.html"),
),
(
"admin/db_browser.html",
include_str!("../assets/templates/admin/db_browser.html"),
),
(
"admin/bulk_confirm_action.html",
include_str!("../assets/templates/admin/bulk_confirm_action.html"),
),
(
"admin/error.html",
include_str!("../assets/templates/admin/error.html"),
),
(
"admin/forbidden.html",
include_str!("../assets/templates/admin/forbidden.html"),
),
(
"admin/object_history.html",
include_str!("../assets/templates/admin/object_history.html"),
),
(
"admin/log_entries.html",
include_str!("../assets/templates/admin/log_entries.html"),
),
(
"admin/apis_index.html",
include_str!("../assets/templates/admin/apis_index.html"),
),
(
"admin/apis_playground.html",
include_str!("../assets/templates/admin/apis_playground.html"),
),
(
"admin/health.html",
include_str!("../assets/templates/admin/health.html"),
),
(
"admin/feature_flags.html",
include_str!("../assets/templates/admin/feature_flags.html"),
),
(
"admin/notifications.html",
include_str!("../assets/templates/admin/notifications.html"),
),
(
"admin/csv_import_result.html",
include_str!("../assets/templates/admin/csv_import_result.html"),
),
(
"admin/docs_index.html",
include_str!("../assets/templates/admin/docs_index.html"),
),
(
"admin/doc_page.html",
include_str!("../assets/templates/admin/doc_page.html"),
),
(
"admin/password_change.html",
include_str!("../assets/templates/admin/password_change.html"),
),
(
"admin/users_list.html",
include_str!("../assets/templates/admin/users_list.html"),
),
(
"admin/user_new.html",
include_str!("../assets/templates/admin/user_new.html"),
),
(
"admin/user_edit.html",
include_str!("../assets/templates/admin/user_edit.html"),
),
(
"admin/user_view.html",
include_str!("../assets/templates/admin/user_view.html"),
),
(
"admin/user_confirm_delete.html",
include_str!("../assets/templates/admin/user_confirm_delete.html"),
),
(
"admin/groups_list.html",
include_str!("../assets/templates/admin/groups_list.html"),
),
(
"admin/group_new.html",
include_str!("../assets/templates/admin/group_new.html"),
),
(
"admin/group_edit.html",
include_str!("../assets/templates/admin/group_edit.html"),
),
(
"admin/group_confirm_delete.html",
include_str!("../assets/templates/admin/group_confirm_delete.html"),
),
(
"admin/account_sessions.html",
include_str!("../assets/templates/admin/account_sessions.html"),
),
(
"admin/forgot_password.html",
include_str!("../assets/templates/admin/forgot_password.html"),
),
(
"admin/forgot_password_sent.html",
include_str!("../assets/templates/admin/forgot_password_sent.html"),
),
(
"admin/reset_password.html",
include_str!("../assets/templates/admin/reset_password.html"),
),
(
"admin/reauth.html",
include_str!("../assets/templates/admin/reauth.html"),
),
(
"admin/admin_reset_password.html",
include_str!("../assets/templates/admin/admin_reset_password.html"),
),
(
"admin/lock_user.html",
include_str!("../assets/templates/admin/lock_user.html"),
),
(
"admin/confirm_admin_action.html",
include_str!("../assets/templates/admin/confirm_admin_action.html"),
),
(
"admin/must_change_password.html",
include_str!("../assets/templates/admin/must_change_password.html"),
),
(
"admin/mfa_enroll.html",
include_str!("../assets/templates/admin/mfa_enroll.html"),
),
(
"admin/mfa_enroll_complete.html",
include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
),
(
"admin/mfa_verify.html",
include_str!("../assets/templates/admin/mfa_verify.html"),
),
(
"admin/mfa_disable.html",
include_str!("../assets/templates/admin/mfa_disable.html"),
),
(
"admin/mfa_regenerate.html",
include_str!("../assets/templates/admin/mfa_regenerate.html"),
),
(
"admin/mfa_regenerate_complete.html",
include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
),
];
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
use std::io::Write;
#[derive(Serialize)]
struct Empty {}
fn tempdir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"rustio-admin-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_template_errors_cleanly() {
let t = Templates::new(None).unwrap();
let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
assert_eq!(err.status(), 500);
}
#[test]
fn disk_loader_finds_project_template() {
let dir = tempdir();
let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
f.write_all(b"hi from disk").unwrap();
drop(f);
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t.render("hello.html", &Empty {}).unwrap();
assert_eq!(body, "hi from disk");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_for_model_prefers_per_model_override() {
let dir = tempdir();
std::fs::create_dir_all(dir.join("admin/books")).unwrap();
let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
f.write_all(b"books-specific list").unwrap();
drop(f);
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t
.render_for_model("books", "admin/list.html", &Empty {})
.unwrap();
assert_eq!(body, "books-specific list");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_for_model_falls_through_to_framework_default() {
let dir = tempdir();
std::fs::create_dir_all(dir.join("admin/books")).unwrap();
let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
f.write_all(b"books override").unwrap();
drop(f);
std::fs::create_dir_all(dir.join("admin")).unwrap();
let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
f.write_all(b"framework-wide list").unwrap();
drop(f);
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t
.render_for_model("authors", "admin/list.html", &Empty {})
.unwrap();
assert_eq!(body, "framework-wide list");
let body = t
.render_for_model("books", "admin/list.html", &Empty {})
.unwrap();
assert_eq!(body, "books override");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_embedded_template_loads() {
let t = Templates::new(None).unwrap();
for (name, _) in EMBEDDED_TEMPLATES {
let result = t.render(name, &Empty {});
if let Err(e) = result {
let msg = e.to_string();
assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
}
}
}
#[test]
fn every_handler_rendered_template_resolves() {
let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
extract_template_names(&content, &mut names);
});
assert!(
!names.is_empty(),
"no template names found — scan regression?"
);
let t = Templates::new(None).unwrap();
let mut missing: Vec<String> = Vec::new();
for name in &names {
let result = t.render(name, &Empty {});
if let Err(e) = result {
let msg = e.to_string();
if msg.contains("not found") {
missing.push(format!("{name}: {msg}"));
}
}
}
assert!(
missing.is_empty(),
"templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n {}",
missing.join("\n ")
);
}
fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
let needle = "\"admin/";
let mut cursor = 0;
while let Some(idx) = content[cursor..].find(needle) {
let start = cursor + idx + 1; let after = &content[start..];
if let Some(end_rel) = after.find('"') {
let literal = &after[..end_rel];
if literal.ends_with(".html") {
out.insert(literal.to_string());
}
cursor = start + end_rel + 1;
} else {
break;
}
}
}
fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_dir() {
walk_rs_files(&path, visit);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
visit(&path);
}
}
}
}