Skip to main content

rustio_admin/
templates.rs

1//! Template rendering. Rust code passes typed context; this module
2//! owns everything about HTML generation.
3//!
4//! # Loader contract
5//!
6//! Per-request lookup via [`minijinja::Environment::set_loader`]. On
7//! every `render` call the cache is cleared, forcing the loader closure
8//! to re-resolve from disk so a developer can edit a template under
9//! `RUSTIO_TEMPLATE_DIR` and see the change on the next request without
10//! restarting the process.
11//!
12//! Lookup order, by template name `<path>`:
13//!
14//! 1. `<RUSTIO_TEMPLATE_DIR>/<path>` — project disk override.
15//! 2. Embedded default — compiled into the binary via `include_str!`.
16//!
17//! Per-model lookup hook: callers that pass a model context can use
18//! [`Templates::render_for_model`] to add a third tier:
19//!
20//! 1. `<RUSTIO_TEMPLATE_DIR>/admin/<model>/<page>.html`
21//! 2. `<RUSTIO_TEMPLATE_DIR>/<path>`
22//! 3. Embedded default
23
24use 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    /// Build the environment.
38    ///
39    /// `project_templates_dir = None` → embedded templates only.
40    /// `project_templates_dir = Some(path)` → disk overrides win at
41    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
42    /// own resolved path) here.
43    ///
44    /// When a disk root is supplied, the constructor scans it once for
45    /// overrides of embedded templates. Each match is logged at INFO;
46    /// an override that looks structurally incomplete (no
47    /// `{% extends %}`, no `{% block %}`, no `<html>` tag) is logged at
48    /// WARN so a one-line stub of an admin template stops being a
49    /// silent failure. Non-fatal: the override is still served — the
50    /// scan exists only to make the failure mode visible.
51    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        // `icon(name, class="…")` returns inline SVG for one of the
89        // lucide stroke icons baked at compile time. Templates use it
90        // to render sidebar nav icons, button icons, and alert glyphs
91        // without an extra HTTP round trip. See `admin/icons.rs` for
92        // the catalogue.
93        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            // The output is HTML — minijinja's autoescape would
97            // mangle it. Wrap in `safe()` so it renders as markup.
98            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    /// Render a template by name.
109    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        // Clear cache so the loader runs again — restart-free dev edits.
115        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    /// Render with a per-model override hook.
126    ///
127    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
128    /// stripped of any leading `admin/`), falling back to `name`.
129    #[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/// Outcome of inspecting one project override file at startup.
157/// Per-file, not per-render: the cost is paid once when the `Templates`
158/// arc is built, not on every request.
159///
160/// Pure data — `Templates::new` translates each variant to a log line.
161/// Returned as a `Vec` so unit tests can assert on the structural
162/// classification without scraping log output.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub(crate) enum OverrideValidation {
165    /// File loaded and contains at least one of `{% extends %}`,
166    /// `{% block %}`, or `<html`.
167    Loaded { name: &'static str, bytes: usize },
168    /// File loaded but contains none of the structural markers.
169    Suspicious { name: &'static str, bytes: usize },
170    /// File exists on disk but `read_to_string` failed.
171    Unreadable { name: &'static str, error: String },
172    /// File in `templates/admin/` whose name does NOT match any
173    /// embedded template — usually a typo or a misplaced project
174    /// admin page.
175    OrphanAdminFile { path: String },
176}
177
178/// Walk `EMBEDDED_TEMPLATES`, classify any project override of one of
179/// those names, return the per-file results.
180///
181/// Files in `disk_root` that do NOT shadow an embedded name are
182/// ignored: those are project-only templates and have no framework
183/// default to compare against.
184pub(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    // Orphan-admin-file scan. The framework reserves `templates/admin/`
213    // for overrides of embedded admin templates. A file in that
214    // namespace whose name doesn't match any embedded template
215    // overrides nothing — usually a typo or misunderstanding. Either
216    // way the developer's intent and the runtime's behaviour disagree
217    // silently; this scan logs a WARN so the disagreement becomes
218    // observable.
219    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            // Sort for deterministic ordering — the loop visits files in
227            // arbitrary FS order otherwise, which makes log lines and
228            // tests non-deterministic.
229            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
282/// Baked into the binary. Single-binary deploy is a hard constraint.
283const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
284    // Shell + partials
285    (
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    // Generic pages
310    (
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    // Audit / password change
347    (
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    // Built-in user pages
360    (
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    // Built-in group pages
381    (
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    // Self-service account pages (R0+)
398    (
399        "admin/account_sessions.html",
400        include_str!("../assets/templates/admin/account_sessions.html"),
401    ),
402    // Self-service password recovery (R1)
403    (
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    // Organisational recovery (R2)
416    //
417    // These pages are rendered by `admin/admin_recovery_handlers.rs`
418    // — the admin-driven reset / lock / re-auth / forced-rotation
419    // surface. They were inadvertently omitted from this list when
420    // R2 shipped in 0.6.0; the disk files were committed but never
421    // hooked into the embedded set. Without them every
422    // `/admin/reauth`, `/admin/users/:id/reset-password`,
423    // `/admin/users/:id/lock`, and forced-password-change request
424    // returns the framework's generic 500 page. The
425    // `every_handler_rendered_template_resolves` test in this file
426    // is the regression gate that catches this shape of omission.
427    (
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    // TOTP MFA (R3)
448    //
449    // Rendered by `admin/mfa_handlers.rs` (enrol / verify /
450    // regenerate / disable). Same shape of omission as the R2 set
451    // above; same regression-gate test covers them.
452    (
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    /// Every embedded template is registered. Catches typos in
521    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
522    #[test]
523    fn every_embedded_template_loads() {
524        let t = Templates::new(None).unwrap();
525        for (name, _) in EMBEDDED_TEMPLATES {
526            // Render with an empty serializable; minijinja's
527            // strict-undefined fails on missing variables, so most
528            // pages will Err — but parsing happens before evaluation.
529            // We accept any Err whose underlying minijinja error is a
530            // template-evaluation problem; an Error::Internal that
531            // says "template <name> not found" would mean the loader
532            // failed entirely (regression).
533            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    /// Regression gate for the 0.7.0 → 0.7.1 fix.
542    ///
543    /// Scans every `.rs` file under `src/admin/` for string
544    /// literals of the shape `"admin/<name>.html"` and asserts each
545    /// one resolves via `Templates::new(None)?`. If a handler is
546    /// added that renders a new template and the author forgets to
547    /// extend `EMBEDDED_TEMPLATES`, this test fails before the
548    /// release ships rather than after the first user clicks the
549    /// new page.
550    ///
551    /// The 0.6.0 R2 + 0.7.0 R3 cycles both shipped with this
552    /// shape of bug — the disk template files were committed, the
553    /// handlers rendered them, the bug was invisible to unit tests
554    /// (no integration test boots a real HTTP stack against the
555    /// affected routes), and the regression surfaced only when the
556    /// flagship downstream walked the surface against a live DB.
557    /// This test makes the discipline mechanical.
558    #[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        // Bare-literal scan — the framework only uses
563        // `"admin/<...>.html"` strings as template names, so
564        // finding every literal of that shape catches the entire
565        // surface without needing AST parsing or a regex
566        // dependency.
567        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                // `not found` is minijinja's "template not in
584                // loader" error. Other errors (strict-undefined,
585                // type mismatches) are tolerated — the test cares
586                // only about loader resolution, not full render
587                // success against an empty context.
588                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    /// Pull every `"admin/<...>.html"` literal out of `content` and
601    /// stuff it into `out`. Bare-string scan; tolerates string
602    /// literals that span multiple lines because the closing
603    /// `.html"` must appear before the next double-quote.
604    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; // past the opening quote
609            let after = &content[start..];
610            // The literal ends at the next `"`. If `.html` does
611            // not appear before that quote, this isn't a template
612            // reference (it could be e.g. a Permission action_name
613            // that happens to start with `admin/`).
614            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    /// Recursively walk every `.rs` file under `root`, calling
627    /// `visit` for each. Std-only — no `walkdir` dep needed for a
628    /// single test.
629    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}