1use std::path::PathBuf;
25use std::sync::{Arc, Mutex};
26
27use minijinja::{Environment, ErrorKind};
28use serde::Serialize;
29
30use crate::error::{Error, Result};
31
32pub struct Templates {
34 env: Mutex<Environment<'static>>,
35}
36
37impl Templates {
38 pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
54 let disk_root = project_templates_dir;
55 if let Some(root) = disk_root.as_deref() {
56 for v in validate_overrides(root) {
57 match v {
58 OverrideValidation::Loaded { name, bytes } => {
59 log::info!(
60 "templates: project override loaded for `{name}` ({bytes} bytes)"
61 );
62 }
63 OverrideValidation::Suspicious { name, bytes } => {
64 log::warn!(
65 "templates: project override for `{name}` looks incomplete \
66 ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
67 `<html>` tag) — the admin UI may render incorrectly. Either \
68 copy the framework default in full or remove the override."
69 );
70 }
71 OverrideValidation::Unreadable { name, error } => {
72 log::warn!(
73 "templates: project override `{name}` exists but cannot be read: {error}"
74 );
75 }
76 OverrideValidation::OrphanAdminFile { path } => {
77 log::warn!(
78 "templates: `{path}` is in the admin namespace but does not \
79 override any embedded template (typo? framework default \
80 will be served unchanged). Project-specific admin pages \
81 belong outside `templates/admin/`."
82 );
83 }
84 }
85 }
86 }
87 let mut env = Environment::new();
88 env.set_loader(move |name| load_template(disk_root.as_deref(), name));
89
90 env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
96 let class: String = kwargs.get("class").unwrap_or_default();
97 kwargs.assert_all_used().ok();
98 minijinja::value::Value::from_safe_string(crate::admin::icons::render_inline(
101 name, &class,
102 ))
103 });
104
105 Ok(Arc::new(Self {
106 env: Mutex::new(env),
107 }))
108 }
109
110 pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
113 let mut env = self
114 .env
115 .lock()
116 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
117 env.clear_templates();
119 let tmpl = env
120 .get_template(name)
121 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
122 tmpl.render(ctx).map_err(|e| {
123 log::error!("template render failed for {name}: {e:?}");
124 Error::Internal(format!("render {name}: {e}"))
125 })
126 }
127
128 #[allow(dead_code)]
134 pub fn render_for_model<S: Serialize>(
135 &self,
136 model: &str,
137 name: &str,
138 ctx: &S,
139 ) -> Result<String> {
140 let page = name.strip_prefix("admin/").unwrap_or(name);
141 let per_model = format!("admin/{model}/{page}");
142 let mut env = self
143 .env
144 .lock()
145 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
146 env.clear_templates();
147 if let Ok(tmpl) = env.get_template(&per_model) {
148 return tmpl
149 .render(ctx)
150 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
151 }
152 let tmpl = env
153 .get_template(name)
154 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
155 tmpl.render(ctx)
156 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
168pub(crate) enum OverrideValidation {
169 Loaded { name: &'static str, bytes: usize },
172 Suspicious { name: &'static str, bytes: usize },
174 Unreadable { name: &'static str, error: String },
176 OrphanAdminFile { path: String },
180}
181
182pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
189 let mut results = Vec::new();
190 for (name, _embedded) in EMBEDDED_TEMPLATES {
191 let path = disk_root.join(name);
192 if !path.is_file() {
193 continue;
194 }
195 match std::fs::read_to_string(&path) {
196 Ok(body) => {
197 let bytes = body.len();
198 let has_structure = body.contains("{% extends")
199 || body.contains("{% block")
200 || body.contains("<html");
201 if has_structure {
202 results.push(OverrideValidation::Loaded { name, bytes });
203 } else {
204 results.push(OverrideValidation::Suspicious { name, bytes });
205 }
206 }
207 Err(e) => {
208 results.push(OverrideValidation::Unreadable {
209 name,
210 error: e.to_string(),
211 });
212 }
213 }
214 }
215
216 let admin_dir = disk_root.join("admin");
224 if admin_dir.is_dir() {
225 let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
226 .iter()
227 .filter_map(|(n, _)| n.strip_prefix("admin/"))
228 .collect();
229 if let Ok(entries) = std::fs::read_dir(&admin_dir) {
230 let mut files: Vec<_> = entries
234 .filter_map(|e| e.ok())
235 .filter(|e| {
236 e.path()
237 .extension()
238 .and_then(|s| s.to_str())
239 .map(|s| s.eq_ignore_ascii_case("html"))
240 .unwrap_or(false)
241 })
242 .collect();
243 files.sort_by_key(|e| e.file_name());
244 for entry in files {
245 let file_name = entry.file_name();
246 let Some(stem_html) = file_name.to_str() else {
247 continue;
248 };
249 if known.contains(stem_html) {
250 continue;
251 }
252 results.push(OverrideValidation::OrphanAdminFile {
253 path: format!("admin/{stem_html}"),
254 });
255 }
256 }
257 }
258
259 results
260}
261
262fn load_template(
263 disk_root: Option<&std::path::Path>,
264 name: &str,
265) -> std::result::Result<Option<String>, minijinja::Error> {
266 if let Some(root) = disk_root {
267 let path = root.join(name);
268 if path.exists() {
269 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
270 minijinja::Error::new(
271 ErrorKind::InvalidOperation,
272 format!("read template {}: {e}", path.display()),
273 )
274 });
275 }
276 }
277 Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
278 if *n == name {
279 Some((*b).to_string())
280 } else {
281 None
282 }
283 }))
284}
285
286const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
288 (
290 "admin/_base.html",
291 include_str!("../assets/templates/admin/_base.html"),
292 ),
293 (
294 "admin/_topbar.html",
295 include_str!("../assets/templates/admin/_topbar.html"),
296 ),
297 (
298 "admin/_sidebar.html",
299 include_str!("../assets/templates/admin/_sidebar.html"),
300 ),
301 (
302 "admin/_theme.html",
303 include_str!("../assets/templates/admin/_theme.html"),
304 ),
305 (
306 "admin/includes/_form_field.html",
307 include_str!("../assets/templates/admin/includes/_form_field.html"),
308 ),
309 (
310 "admin/includes/_field_errors.html",
311 include_str!("../assets/templates/admin/includes/_field_errors.html"),
312 ),
313 (
315 "admin/login.html",
316 include_str!("../assets/templates/admin/login.html"),
317 ),
318 (
319 "admin/index.html",
320 include_str!("../assets/templates/admin/index.html"),
321 ),
322 (
323 "admin/list.html",
324 include_str!("../assets/templates/admin/list.html"),
325 ),
326 (
327 "admin/form.html",
328 include_str!("../assets/templates/admin/form.html"),
329 ),
330 (
331 "admin/confirm_delete.html",
332 include_str!("../assets/templates/admin/confirm_delete.html"),
333 ),
334 (
335 "admin/bulk_confirm_delete.html",
336 include_str!("../assets/templates/admin/bulk_confirm_delete.html"),
337 ),
338 (
339 "admin/bulk_confirm_action.html",
340 include_str!("../assets/templates/admin/bulk_confirm_action.html"),
341 ),
342 (
343 "admin/error.html",
344 include_str!("../assets/templates/admin/error.html"),
345 ),
346 (
347 "admin/forbidden.html",
348 include_str!("../assets/templates/admin/forbidden.html"),
349 ),
350 (
352 "admin/object_history.html",
353 include_str!("../assets/templates/admin/object_history.html"),
354 ),
355 (
356 "admin/log_entries.html",
357 include_str!("../assets/templates/admin/log_entries.html"),
358 ),
359 (
360 "admin/password_change.html",
361 include_str!("../assets/templates/admin/password_change.html"),
362 ),
363 (
365 "admin/users_list.html",
366 include_str!("../assets/templates/admin/users_list.html"),
367 ),
368 (
369 "admin/user_new.html",
370 include_str!("../assets/templates/admin/user_new.html"),
371 ),
372 (
373 "admin/user_edit.html",
374 include_str!("../assets/templates/admin/user_edit.html"),
375 ),
376 (
377 "admin/user_view.html",
378 include_str!("../assets/templates/admin/user_view.html"),
379 ),
380 (
381 "admin/user_confirm_delete.html",
382 include_str!("../assets/templates/admin/user_confirm_delete.html"),
383 ),
384 (
386 "admin/groups_list.html",
387 include_str!("../assets/templates/admin/groups_list.html"),
388 ),
389 (
390 "admin/group_new.html",
391 include_str!("../assets/templates/admin/group_new.html"),
392 ),
393 (
394 "admin/group_edit.html",
395 include_str!("../assets/templates/admin/group_edit.html"),
396 ),
397 (
398 "admin/group_confirm_delete.html",
399 include_str!("../assets/templates/admin/group_confirm_delete.html"),
400 ),
401 (
403 "admin/account_sessions.html",
404 include_str!("../assets/templates/admin/account_sessions.html"),
405 ),
406 (
408 "admin/forgot_password.html",
409 include_str!("../assets/templates/admin/forgot_password.html"),
410 ),
411 (
412 "admin/forgot_password_sent.html",
413 include_str!("../assets/templates/admin/forgot_password_sent.html"),
414 ),
415 (
416 "admin/reset_password.html",
417 include_str!("../assets/templates/admin/reset_password.html"),
418 ),
419 (
432 "admin/reauth.html",
433 include_str!("../assets/templates/admin/reauth.html"),
434 ),
435 (
436 "admin/admin_reset_password.html",
437 include_str!("../assets/templates/admin/admin_reset_password.html"),
438 ),
439 (
440 "admin/lock_user.html",
441 include_str!("../assets/templates/admin/lock_user.html"),
442 ),
443 (
444 "admin/confirm_admin_action.html",
445 include_str!("../assets/templates/admin/confirm_admin_action.html"),
446 ),
447 (
448 "admin/must_change_password.html",
449 include_str!("../assets/templates/admin/must_change_password.html"),
450 ),
451 (
457 "admin/mfa_enroll.html",
458 include_str!("../assets/templates/admin/mfa_enroll.html"),
459 ),
460 (
461 "admin/mfa_enroll_complete.html",
462 include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
463 ),
464 (
465 "admin/mfa_verify.html",
466 include_str!("../assets/templates/admin/mfa_verify.html"),
467 ),
468 (
469 "admin/mfa_disable.html",
470 include_str!("../assets/templates/admin/mfa_disable.html"),
471 ),
472 (
473 "admin/mfa_regenerate.html",
474 include_str!("../assets/templates/admin/mfa_regenerate.html"),
475 ),
476 (
477 "admin/mfa_regenerate_complete.html",
478 include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
479 ),
480];
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use serde::Serialize;
486 use std::io::Write;
487
488 #[derive(Serialize)]
489 struct Empty {}
490
491 fn tempdir() -> std::path::PathBuf {
492 let dir = std::env::temp_dir().join(format!(
493 "rustio-admin-test-{}",
494 std::time::SystemTime::now()
495 .duration_since(std::time::UNIX_EPOCH)
496 .unwrap()
497 .as_nanos()
498 ));
499 std::fs::create_dir_all(&dir).unwrap();
500 dir
501 }
502
503 #[test]
504 fn missing_template_errors_cleanly() {
505 let t = Templates::new(None).unwrap();
506 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
507 assert_eq!(err.status(), 500);
508 }
509
510 #[test]
511 fn disk_loader_finds_project_template() {
512 let dir = tempdir();
513 let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
514 f.write_all(b"hi from disk").unwrap();
515 drop(f);
516
517 let t = Templates::new(Some(dir.clone())).unwrap();
518 let body = t.render("hello.html", &Empty {}).unwrap();
519 assert_eq!(body, "hi from disk");
520
521 let _ = std::fs::remove_dir_all(&dir);
522 }
523
524 #[test]
527 fn every_embedded_template_loads() {
528 let t = Templates::new(None).unwrap();
529 for (name, _) in EMBEDDED_TEMPLATES {
530 let result = t.render(name, &Empty {});
538 if let Err(e) = result {
539 let msg = e.to_string();
540 assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
541 }
542 }
543 }
544
545 #[test]
563 fn every_handler_rendered_template_resolves() {
564 let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
565 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
566 walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
572 let content = std::fs::read_to_string(path)
573 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
574 extract_template_names(&content, &mut names);
575 });
576 assert!(
577 !names.is_empty(),
578 "no template names found — scan regression?"
579 );
580
581 let t = Templates::new(None).unwrap();
582 let mut missing: Vec<String> = Vec::new();
583 for name in &names {
584 let result = t.render(name, &Empty {});
585 if let Err(e) = result {
586 let msg = e.to_string();
587 if msg.contains("not found") {
593 missing.push(format!("{name}: {msg}"));
594 }
595 }
596 }
597 assert!(
598 missing.is_empty(),
599 "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n {}",
600 missing.join("\n ")
601 );
602 }
603
604 fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
609 let needle = "\"admin/";
610 let mut cursor = 0;
611 while let Some(idx) = content[cursor..].find(needle) {
612 let start = cursor + idx + 1; let after = &content[start..];
614 if let Some(end_rel) = after.find('"') {
619 let literal = &after[..end_rel];
620 if literal.ends_with(".html") {
621 out.insert(literal.to_string());
622 }
623 cursor = start + end_rel + 1;
624 } else {
625 break;
626 }
627 }
628 }
629
630 fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
634 let entries = match std::fs::read_dir(root) {
635 Ok(e) => e,
636 Err(_) => return,
637 };
638 for entry in entries.flatten() {
639 let path = entry.path();
640 let file_type = match entry.file_type() {
641 Ok(ft) => ft,
642 Err(_) => continue,
643 };
644 if file_type.is_dir() {
645 walk_rs_files(&path, visit);
646 } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
647 visit(&path);
648 }
649 }
650 }
651}