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/notifications.html",
414 include_str!("../assets/templates/admin/notifications.html"),
415 ),
416 (
417 "admin/csv_import_result.html",
418 include_str!("../assets/templates/admin/csv_import_result.html"),
419 ),
420 (
421 "admin/docs_index.html",
422 include_str!("../assets/templates/admin/docs_index.html"),
423 ),
424 (
425 "admin/doc_page.html",
426 include_str!("../assets/templates/admin/doc_page.html"),
427 ),
428 (
429 "admin/password_change.html",
430 include_str!("../assets/templates/admin/password_change.html"),
431 ),
432 (
434 "admin/users_list.html",
435 include_str!("../assets/templates/admin/users_list.html"),
436 ),
437 (
438 "admin/user_new.html",
439 include_str!("../assets/templates/admin/user_new.html"),
440 ),
441 (
442 "admin/user_edit.html",
443 include_str!("../assets/templates/admin/user_edit.html"),
444 ),
445 (
446 "admin/user_view.html",
447 include_str!("../assets/templates/admin/user_view.html"),
448 ),
449 (
450 "admin/user_confirm_delete.html",
451 include_str!("../assets/templates/admin/user_confirm_delete.html"),
452 ),
453 (
455 "admin/groups_list.html",
456 include_str!("../assets/templates/admin/groups_list.html"),
457 ),
458 (
459 "admin/group_new.html",
460 include_str!("../assets/templates/admin/group_new.html"),
461 ),
462 (
463 "admin/group_edit.html",
464 include_str!("../assets/templates/admin/group_edit.html"),
465 ),
466 (
467 "admin/group_confirm_delete.html",
468 include_str!("../assets/templates/admin/group_confirm_delete.html"),
469 ),
470 (
472 "admin/account_sessions.html",
473 include_str!("../assets/templates/admin/account_sessions.html"),
474 ),
475 (
477 "admin/forgot_password.html",
478 include_str!("../assets/templates/admin/forgot_password.html"),
479 ),
480 (
481 "admin/forgot_password_sent.html",
482 include_str!("../assets/templates/admin/forgot_password_sent.html"),
483 ),
484 (
485 "admin/reset_password.html",
486 include_str!("../assets/templates/admin/reset_password.html"),
487 ),
488 (
501 "admin/reauth.html",
502 include_str!("../assets/templates/admin/reauth.html"),
503 ),
504 (
505 "admin/admin_reset_password.html",
506 include_str!("../assets/templates/admin/admin_reset_password.html"),
507 ),
508 (
509 "admin/lock_user.html",
510 include_str!("../assets/templates/admin/lock_user.html"),
511 ),
512 (
513 "admin/confirm_admin_action.html",
514 include_str!("../assets/templates/admin/confirm_admin_action.html"),
515 ),
516 (
517 "admin/must_change_password.html",
518 include_str!("../assets/templates/admin/must_change_password.html"),
519 ),
520 (
526 "admin/mfa_enroll.html",
527 include_str!("../assets/templates/admin/mfa_enroll.html"),
528 ),
529 (
530 "admin/mfa_enroll_complete.html",
531 include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
532 ),
533 (
534 "admin/mfa_verify.html",
535 include_str!("../assets/templates/admin/mfa_verify.html"),
536 ),
537 (
538 "admin/mfa_disable.html",
539 include_str!("../assets/templates/admin/mfa_disable.html"),
540 ),
541 (
542 "admin/mfa_regenerate.html",
543 include_str!("../assets/templates/admin/mfa_regenerate.html"),
544 ),
545 (
546 "admin/mfa_regenerate_complete.html",
547 include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
548 ),
549];
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use serde::Serialize;
555 use std::io::Write;
556
557 #[derive(Serialize)]
558 struct Empty {}
559
560 fn tempdir() -> std::path::PathBuf {
561 let dir = std::env::temp_dir().join(format!(
562 "rustio-admin-test-{}",
563 std::time::SystemTime::now()
564 .duration_since(std::time::UNIX_EPOCH)
565 .unwrap()
566 .as_nanos()
567 ));
568 std::fs::create_dir_all(&dir).unwrap();
569 dir
570 }
571
572 #[test]
573 fn missing_template_errors_cleanly() {
574 let t = Templates::new(None).unwrap();
575 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
576 assert_eq!(err.status(), 500);
577 }
578
579 #[test]
580 fn disk_loader_finds_project_template() {
581 let dir = tempdir();
582 let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
583 f.write_all(b"hi from disk").unwrap();
584 drop(f);
585
586 let t = Templates::new(Some(dir.clone())).unwrap();
587 let body = t.render("hello.html", &Empty {}).unwrap();
588 assert_eq!(body, "hi from disk");
589
590 let _ = std::fs::remove_dir_all(&dir);
591 }
592
593 #[test]
599 fn render_for_model_prefers_per_model_override() {
600 let dir = tempdir();
601 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
602 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
603 f.write_all(b"books-specific list").unwrap();
604 drop(f);
605
606 let t = Templates::new(Some(dir.clone())).unwrap();
607 let body = t
609 .render_for_model("books", "admin/list.html", &Empty {})
610 .unwrap();
611 assert_eq!(body, "books-specific list");
612 let _ = std::fs::remove_dir_all(&dir);
613 }
614
615 #[test]
619 fn render_for_model_falls_through_to_framework_default() {
620 let dir = tempdir();
621 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
623 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
624 f.write_all(b"books override").unwrap();
625 drop(f);
626 std::fs::create_dir_all(dir.join("admin")).unwrap();
630 let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
631 f.write_all(b"framework-wide list").unwrap();
632 drop(f);
633
634 let t = Templates::new(Some(dir.clone())).unwrap();
635 let body = t
638 .render_for_model("authors", "admin/list.html", &Empty {})
639 .unwrap();
640 assert_eq!(body, "framework-wide list");
641 let body = t
643 .render_for_model("books", "admin/list.html", &Empty {})
644 .unwrap();
645 assert_eq!(body, "books override");
646 let _ = std::fs::remove_dir_all(&dir);
647 }
648
649 #[test]
652 fn every_embedded_template_loads() {
653 let t = Templates::new(None).unwrap();
654 for (name, _) in EMBEDDED_TEMPLATES {
655 let result = t.render(name, &Empty {});
663 if let Err(e) = result {
664 let msg = e.to_string();
665 assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
666 }
667 }
668 }
669
670 #[test]
688 fn every_handler_rendered_template_resolves() {
689 let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
690 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
691 walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
697 let content = std::fs::read_to_string(path)
698 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
699 extract_template_names(&content, &mut names);
700 });
701 assert!(
702 !names.is_empty(),
703 "no template names found — scan regression?"
704 );
705
706 let t = Templates::new(None).unwrap();
707 let mut missing: Vec<String> = Vec::new();
708 for name in &names {
709 let result = t.render(name, &Empty {});
710 if let Err(e) = result {
711 let msg = e.to_string();
712 if msg.contains("not found") {
718 missing.push(format!("{name}: {msg}"));
719 }
720 }
721 }
722 assert!(
723 missing.is_empty(),
724 "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n {}",
725 missing.join("\n ")
726 );
727 }
728
729 fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
734 let needle = "\"admin/";
735 let mut cursor = 0;
736 while let Some(idx) = content[cursor..].find(needle) {
737 let start = cursor + idx + 1; let after = &content[start..];
739 if let Some(end_rel) = after.find('"') {
744 let literal = &after[..end_rel];
745 if literal.ends_with(".html") {
746 out.insert(literal.to_string());
747 }
748 cursor = start + end_rel + 1;
749 } else {
750 break;
751 }
752 }
753 }
754
755 fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
759 let entries = match std::fs::read_dir(root) {
760 Ok(e) => e,
761 Err(_) => return,
762 };
763 for entry in entries.flatten() {
764 let path = entry.path();
765 let file_type = match entry.file_type() {
766 Ok(ft) => ft,
767 Err(_) => continue,
768 };
769 if file_type.is_dir() {
770 walk_rs_files(&path, visit);
771 } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
772 visit(&path);
773 }
774 }
775 }
776}