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 pub fn render_for_model<S: Serialize>(
142 &self,
143 model: &str,
144 name: &str,
145 ctx: &S,
146 ) -> Result<String> {
147 let page = name.strip_prefix("admin/").unwrap_or(name);
148 let per_model = format!("admin/{model}/{page}");
149 let mut env = self
150 .env
151 .lock()
152 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
153 env.clear_templates();
154 if let Ok(tmpl) = env.get_template(&per_model) {
155 return tmpl
156 .render(ctx)
157 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
158 }
159 let tmpl = env
160 .get_template(name)
161 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
162 tmpl.render(ctx)
163 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
175pub(crate) enum OverrideValidation {
176 Loaded { name: &'static str, bytes: usize },
179 Suspicious { name: &'static str, bytes: usize },
181 Unreadable { name: &'static str, error: String },
183 OrphanAdminFile { path: String },
187}
188
189pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
196 let mut results = Vec::new();
197 for (name, _embedded) in EMBEDDED_TEMPLATES {
198 let path = disk_root.join(name);
199 if !path.is_file() {
200 continue;
201 }
202 match std::fs::read_to_string(&path) {
203 Ok(body) => {
204 let bytes = body.len();
205 let has_structure = body.contains("{% extends")
206 || body.contains("{% block")
207 || body.contains("<html");
208 if has_structure {
209 results.push(OverrideValidation::Loaded { name, bytes });
210 } else {
211 results.push(OverrideValidation::Suspicious { name, bytes });
212 }
213 }
214 Err(e) => {
215 results.push(OverrideValidation::Unreadable {
216 name,
217 error: e.to_string(),
218 });
219 }
220 }
221 }
222
223 let admin_dir = disk_root.join("admin");
231 if admin_dir.is_dir() {
232 let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
233 .iter()
234 .filter_map(|(n, _)| n.strip_prefix("admin/"))
235 .collect();
236 if let Ok(entries) = std::fs::read_dir(&admin_dir) {
237 let mut files: Vec<_> = entries
241 .filter_map(|e| e.ok())
242 .filter(|e| {
243 e.path()
244 .extension()
245 .and_then(|s| s.to_str())
246 .map(|s| s.eq_ignore_ascii_case("html"))
247 .unwrap_or(false)
248 })
249 .collect();
250 files.sort_by_key(|e| e.file_name());
251 for entry in files {
252 let file_name = entry.file_name();
253 let Some(stem_html) = file_name.to_str() else {
254 continue;
255 };
256 if known.contains(stem_html) {
257 continue;
258 }
259 results.push(OverrideValidation::OrphanAdminFile {
260 path: format!("admin/{stem_html}"),
261 });
262 }
263 }
264 }
265
266 results
267}
268
269fn load_template(
270 disk_root: Option<&std::path::Path>,
271 name: &str,
272) -> std::result::Result<Option<String>, minijinja::Error> {
273 if let Some(root) = disk_root {
274 let path = root.join(name);
275 if path.exists() {
276 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
277 minijinja::Error::new(
278 ErrorKind::InvalidOperation,
279 format!("read template {}: {e}", path.display()),
280 )
281 });
282 }
283 }
284 Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
285 if *n == name {
286 Some((*b).to_string())
287 } else {
288 None
289 }
290 }))
291}
292
293pub fn embedded_template_names() -> Vec<&'static str> {
300 EMBEDDED_TEMPLATES.iter().map(|(n, _)| *n).collect()
301}
302
303pub fn embedded_template_source(name: &str) -> Option<&'static str> {
310 EMBEDDED_TEMPLATES
311 .iter()
312 .find_map(|(n, body)| if *n == name { Some(*body) } else { None })
313}
314
315const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
317 (
319 "admin/_base.html",
320 include_str!("../assets/templates/admin/_base.html"),
321 ),
322 (
323 "admin/_topbar.html",
324 include_str!("../assets/templates/admin/_topbar.html"),
325 ),
326 (
327 "admin/_sidebar.html",
328 include_str!("../assets/templates/admin/_sidebar.html"),
329 ),
330 (
331 "admin/_theme.html",
332 include_str!("../assets/templates/admin/_theme.html"),
333 ),
334 (
335 "admin/_row_actions.html",
336 include_str!("../assets/templates/admin/_row_actions.html"),
337 ),
338 (
339 "admin/includes/_form_field.html",
340 include_str!("../assets/templates/admin/includes/_form_field.html"),
341 ),
342 (
343 "admin/includes/_field_errors.html",
344 include_str!("../assets/templates/admin/includes/_field_errors.html"),
345 ),
346 (
348 "admin/login.html",
349 include_str!("../assets/templates/admin/login.html"),
350 ),
351 (
352 "admin/index.html",
353 include_str!("../assets/templates/admin/index.html"),
354 ),
355 (
356 "admin/list.html",
357 include_str!("../assets/templates/admin/list.html"),
358 ),
359 (
360 "admin/form.html",
361 include_str!("../assets/templates/admin/form.html"),
362 ),
363 (
364 "admin/confirm_delete.html",
365 include_str!("../assets/templates/admin/confirm_delete.html"),
366 ),
367 (
368 "admin/bulk_confirm_delete.html",
369 include_str!("../assets/templates/admin/bulk_confirm_delete.html"),
370 ),
371 (
372 "admin/db_browser.html",
373 include_str!("../assets/templates/admin/db_browser.html"),
374 ),
375 (
376 "admin/bulk_confirm_action.html",
377 include_str!("../assets/templates/admin/bulk_confirm_action.html"),
378 ),
379 (
380 "admin/error.html",
381 include_str!("../assets/templates/admin/error.html"),
382 ),
383 (
384 "admin/forbidden.html",
385 include_str!("../assets/templates/admin/forbidden.html"),
386 ),
387 (
389 "admin/object_history.html",
390 include_str!("../assets/templates/admin/object_history.html"),
391 ),
392 (
393 "admin/log_entries.html",
394 include_str!("../assets/templates/admin/log_entries.html"),
395 ),
396 (
397 "admin/apis_index.html",
398 include_str!("../assets/templates/admin/apis_index.html"),
399 ),
400 (
401 "admin/apis_playground.html",
402 include_str!("../assets/templates/admin/apis_playground.html"),
403 ),
404 (
405 "admin/health.html",
406 include_str!("../assets/templates/admin/health.html"),
407 ),
408 (
409 "admin/feature_flags.html",
410 include_str!("../assets/templates/admin/feature_flags.html"),
411 ),
412 (
413 "admin/_list_adaptive.html",
414 include_str!("../assets/templates/admin/_list_adaptive.html"),
415 ),
416 (
417 "admin/view_designer.html",
418 include_str!("../assets/templates/admin/view_designer.html"),
419 ),
420 (
421 "admin/view_designer_model.html",
422 include_str!("../assets/templates/admin/view_designer_model.html"),
423 ),
424 (
425 "admin/branding.html",
426 include_str!("../assets/templates/admin/branding.html"),
427 ),
428 (
429 "admin/schema.html",
430 include_str!("../assets/templates/admin/schema.html"),
431 ),
432 (
433 "admin/view_layer/_cell.html",
434 include_str!("../assets/templates/admin/view_layer/_cell.html"),
435 ),
436 (
437 "admin/view_layer/_row.html",
438 include_str!("../assets/templates/admin/view_layer/_row.html"),
439 ),
440 (
441 "admin/notifications.html",
442 include_str!("../assets/templates/admin/notifications.html"),
443 ),
444 (
445 "admin/csv_import_result.html",
446 include_str!("../assets/templates/admin/csv_import_result.html"),
447 ),
448 (
449 "admin/docs_index.html",
450 include_str!("../assets/templates/admin/docs_index.html"),
451 ),
452 (
453 "admin/doc_page.html",
454 include_str!("../assets/templates/admin/doc_page.html"),
455 ),
456 (
457 "admin/password_change.html",
458 include_str!("../assets/templates/admin/password_change.html"),
459 ),
460 (
462 "admin/users_list.html",
463 include_str!("../assets/templates/admin/users_list.html"),
464 ),
465 (
466 "admin/user_new.html",
467 include_str!("../assets/templates/admin/user_new.html"),
468 ),
469 (
470 "admin/user_edit.html",
471 include_str!("../assets/templates/admin/user_edit.html"),
472 ),
473 (
474 "admin/user_view.html",
475 include_str!("../assets/templates/admin/user_view.html"),
476 ),
477 (
478 "admin/user_confirm_delete.html",
479 include_str!("../assets/templates/admin/user_confirm_delete.html"),
480 ),
481 (
483 "admin/groups_list.html",
484 include_str!("../assets/templates/admin/groups_list.html"),
485 ),
486 (
487 "admin/group_new.html",
488 include_str!("../assets/templates/admin/group_new.html"),
489 ),
490 (
491 "admin/group_edit.html",
492 include_str!("../assets/templates/admin/group_edit.html"),
493 ),
494 (
495 "admin/group_confirm_delete.html",
496 include_str!("../assets/templates/admin/group_confirm_delete.html"),
497 ),
498 (
500 "admin/account_sessions.html",
501 include_str!("../assets/templates/admin/account_sessions.html"),
502 ),
503 (
505 "admin/forgot_password.html",
506 include_str!("../assets/templates/admin/forgot_password.html"),
507 ),
508 (
509 "admin/forgot_password_sent.html",
510 include_str!("../assets/templates/admin/forgot_password_sent.html"),
511 ),
512 (
513 "admin/reset_password.html",
514 include_str!("../assets/templates/admin/reset_password.html"),
515 ),
516 (
529 "admin/reauth.html",
530 include_str!("../assets/templates/admin/reauth.html"),
531 ),
532 (
533 "admin/admin_reset_password.html",
534 include_str!("../assets/templates/admin/admin_reset_password.html"),
535 ),
536 (
537 "admin/lock_user.html",
538 include_str!("../assets/templates/admin/lock_user.html"),
539 ),
540 (
541 "admin/confirm_admin_action.html",
542 include_str!("../assets/templates/admin/confirm_admin_action.html"),
543 ),
544 (
545 "admin/must_change_password.html",
546 include_str!("../assets/templates/admin/must_change_password.html"),
547 ),
548 (
554 "admin/mfa_enroll.html",
555 include_str!("../assets/templates/admin/mfa_enroll.html"),
556 ),
557 (
558 "admin/mfa_enroll_complete.html",
559 include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
560 ),
561 (
562 "admin/mfa_verify.html",
563 include_str!("../assets/templates/admin/mfa_verify.html"),
564 ),
565 (
566 "admin/mfa_disable.html",
567 include_str!("../assets/templates/admin/mfa_disable.html"),
568 ),
569 (
570 "admin/mfa_regenerate.html",
571 include_str!("../assets/templates/admin/mfa_regenerate.html"),
572 ),
573 (
574 "admin/mfa_regenerate_complete.html",
575 include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
576 ),
577];
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use serde::Serialize;
583 use std::io::Write;
584
585 #[derive(Serialize)]
586 struct Empty {}
587
588 fn tempdir() -> std::path::PathBuf {
589 let dir = std::env::temp_dir().join(format!(
590 "rustio-admin-test-{}",
591 std::time::SystemTime::now()
592 .duration_since(std::time::UNIX_EPOCH)
593 .unwrap()
594 .as_nanos()
595 ));
596 std::fs::create_dir_all(&dir).unwrap();
597 dir
598 }
599
600 #[test]
601 fn missing_template_errors_cleanly() {
602 let t = Templates::new(None).unwrap();
603 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
604 assert_eq!(err.status(), 500);
605 }
606
607 #[test]
608 fn disk_loader_finds_project_template() {
609 let dir = tempdir();
610 let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
611 f.write_all(b"hi from disk").unwrap();
612 drop(f);
613
614 let t = Templates::new(Some(dir.clone())).unwrap();
615 let body = t.render("hello.html", &Empty {}).unwrap();
616 assert_eq!(body, "hi from disk");
617
618 let _ = std::fs::remove_dir_all(&dir);
619 }
620
621 #[test]
627 fn render_for_model_prefers_per_model_override() {
628 let dir = tempdir();
629 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
630 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
631 f.write_all(b"books-specific list").unwrap();
632 drop(f);
633
634 let t = Templates::new(Some(dir.clone())).unwrap();
635 let body = t
637 .render_for_model("books", "admin/list.html", &Empty {})
638 .unwrap();
639 assert_eq!(body, "books-specific list");
640 let _ = std::fs::remove_dir_all(&dir);
641 }
642
643 #[test]
647 fn render_for_model_falls_through_to_framework_default() {
648 let dir = tempdir();
649 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
651 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
652 f.write_all(b"books override").unwrap();
653 drop(f);
654 std::fs::create_dir_all(dir.join("admin")).unwrap();
658 let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
659 f.write_all(b"framework-wide list").unwrap();
660 drop(f);
661
662 let t = Templates::new(Some(dir.clone())).unwrap();
663 let body = t
666 .render_for_model("authors", "admin/list.html", &Empty {})
667 .unwrap();
668 assert_eq!(body, "framework-wide list");
669 let body = t
671 .render_for_model("books", "admin/list.html", &Empty {})
672 .unwrap();
673 assert_eq!(body, "books override");
674 let _ = std::fs::remove_dir_all(&dir);
675 }
676
677 #[test]
680 fn every_embedded_template_loads() {
681 let t = Templates::new(None).unwrap();
682 for (name, _) in EMBEDDED_TEMPLATES {
683 let result = t.render(name, &Empty {});
691 if let Err(e) = result {
692 let msg = e.to_string();
693 assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
694 }
695 }
696 }
697
698 #[test]
704 fn sidebar_marks_active_nav_item() {
705 let t = Templates::new(None).unwrap();
706 let render_with = |active: &str| {
707 t.render(
708 "admin/_sidebar.html",
709 &minijinja::context! {
710 app_name => "Test Admin",
711 nav_active => active,
712 identity => minijinja::context! { is_admin => true, is_developer => true },
713 entries => vec![
714 minijinja::context! { admin_name => "customer", display_name => "Customers" },
715 ],
716 },
717 )
718 .unwrap()
719 };
720
721 let users = render_with("users");
723 assert!(
724 users.contains(r#"href="/admin/users" aria-current="page""#),
725 "Users link should be active when nav_active=users"
726 );
727 assert_eq!(
728 users.matches(r#"aria-current="page""#).count(),
729 1,
730 "exactly one rail item is active"
731 );
732
733 let model = render_with("customer");
735 assert!(
736 model.contains(r#"href="/admin/customer" aria-current="page""#),
737 "model link should be active when nav_active matches its admin_name"
738 );
739
740 let designer = render_with("view-designer");
742 assert!(designer.contains(r#"href="/admin/dev/view-designer""#));
743 assert!(
744 designer.contains(r#"href="/admin/dev/view-designer" aria-current="page""#),
745 "View designer link should be active when nav_active=view-designer"
746 );
747
748 let none = render_with("");
750 assert_eq!(none.matches(r#"aria-current="page""#).count(), 0);
751 }
752
753 #[test]
771 fn every_handler_rendered_template_resolves() {
772 let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
773 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
774 walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
780 let content = std::fs::read_to_string(path)
781 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
782 extract_template_names(&content, &mut names);
783 });
784 assert!(
785 !names.is_empty(),
786 "no template names found — scan regression?"
787 );
788
789 let t = Templates::new(None).unwrap();
790 let mut missing: Vec<String> = Vec::new();
791 for name in &names {
792 let result = t.render(name, &Empty {});
793 if let Err(e) = result {
794 let msg = e.to_string();
795 if msg.contains("not found") {
801 missing.push(format!("{name}: {msg}"));
802 }
803 }
804 }
805 assert!(
806 missing.is_empty(),
807 "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n {}",
808 missing.join("\n ")
809 );
810 }
811
812 fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
817 let needle = "\"admin/";
818 let mut cursor = 0;
819 while let Some(idx) = content[cursor..].find(needle) {
820 let start = cursor + idx + 1; let after = &content[start..];
822 if let Some(end_rel) = after.find('"') {
827 let literal = &after[..end_rel];
828 if literal.ends_with(".html") {
829 out.insert(literal.to_string());
830 }
831 cursor = start + end_rel + 1;
832 } else {
833 break;
834 }
835 }
836 }
837
838 fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
842 let entries = match std::fs::read_dir(root) {
843 Ok(e) => e,
844 Err(_) => return,
845 };
846 for entry in entries.flatten() {
847 let path = entry.path();
848 let file_type = match entry.file_type() {
849 Ok(ft) => ft,
850 Err(_) => continue,
851 };
852 if file_type.is_dir() {
853 walk_rs_files(&path, visit);
854 } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
855 visit(&path);
856 }
857 }
858 }
859}