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