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
32// public:
33pub struct Templates {
34    env: Mutex<Environment<'static>>,
35}
36
37impl Templates {
38    // public:
39    /// Build the environment.
40    ///
41    /// `project_templates_dir = None` → embedded templates only.
42    /// `project_templates_dir = Some(path)` → disk overrides win at
43    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
44    /// own resolved path) here.
45    ///
46    /// When a disk root is supplied, the constructor scans it once for
47    /// overrides of embedded templates. Each match is logged at INFO;
48    /// an override that looks structurally incomplete (no
49    /// `{% extends %}`, no `{% block %}`, no `<html>` tag) is logged at
50    /// WARN so a one-line stub of an admin template stops being a
51    /// silent failure. Non-fatal: the override is still served — the
52    /// scan exists only to make the failure mode visible.
53    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        // `icon(name, class="…")` returns inline SVG for one of the
91        // lucide stroke icons baked at compile time. Templates use it
92        // to render sidebar nav icons, button icons, and alert glyphs
93        // without an extra HTTP round trip. See `admin/icons.rs` for
94        // the catalogue.
95        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            // The output is HTML — minijinja's autoescape would
99            // mangle it. Wrap in `safe()` so it renders as markup.
100            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    // public:
111    /// Render a template by name.
112    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        // Clear cache so the loader runs again — restart-free dev edits.
118        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    // public:
129    /// Render with a per-model override hook.
130    ///
131    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
132    /// stripped of any leading `admin/`), falling back to `name`.
133    #[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/// Outcome of inspecting one project override file at startup.
161/// Per-file, not per-render: the cost is paid once when the `Templates`
162/// arc is built, not on every request.
163///
164/// Pure data — `Templates::new` translates each variant to a log line.
165/// Returned as a `Vec` so unit tests can assert on the structural
166/// classification without scraping log output.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub(crate) enum OverrideValidation {
169    /// File loaded and contains at least one of `{% extends %}`,
170    /// `{% block %}`, or `<html`.
171    Loaded { name: &'static str, bytes: usize },
172    /// File loaded but contains none of the structural markers.
173    Suspicious { name: &'static str, bytes: usize },
174    /// File exists on disk but `read_to_string` failed.
175    Unreadable { name: &'static str, error: String },
176    /// File in `templates/admin/` whose name does NOT match any
177    /// embedded template — usually a typo or a misplaced project
178    /// admin page.
179    OrphanAdminFile { path: String },
180}
181
182/// Walk `EMBEDDED_TEMPLATES`, classify any project override of one of
183/// those names, return the per-file results.
184///
185/// Files in `disk_root` that do NOT shadow an embedded name are
186/// ignored: those are project-only templates and have no framework
187/// default to compare against.
188pub(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    // Orphan-admin-file scan. The framework reserves `templates/admin/`
217    // for overrides of embedded admin templates. A file in that
218    // namespace whose name doesn't match any embedded template
219    // overrides nothing — usually a typo or misunderstanding. Either
220    // way the developer's intent and the runtime's behaviour disagree
221    // silently; this scan logs a WARN so the disagreement becomes
222    // observable.
223    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            // Sort for deterministic ordering — the loop visits files in
231            // arbitrary FS order otherwise, which makes log lines and
232            // tests non-deterministic.
233            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
286/// Baked into the binary. Single-binary deploy is a hard constraint.
287const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
288    // Shell + partials
289    (
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    // Generic pages
314    (
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    // Audit / password change
351    (
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    // Built-in user pages
364    (
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    // Built-in group pages
385    (
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    // Self-service account pages (R0+)
402    (
403        "admin/account_sessions.html",
404        include_str!("../assets/templates/admin/account_sessions.html"),
405    ),
406    // Self-service password recovery (R1)
407    (
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    // Organisational recovery (R2)
420    //
421    // These pages are rendered by `admin/admin_recovery_handlers.rs`
422    // — the admin-driven reset / lock / re-auth / forced-rotation
423    // surface. They were inadvertently omitted from this list when
424    // R2 shipped in 0.6.0; the disk files were committed but never
425    // hooked into the embedded set. Without them every
426    // `/admin/reauth`, `/admin/users/:id/reset-password`,
427    // `/admin/users/:id/lock`, and forced-password-change request
428    // returns the framework's generic 500 page. The
429    // `every_handler_rendered_template_resolves` test in this file
430    // is the regression gate that catches this shape of omission.
431    (
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    // TOTP MFA (R3)
452    //
453    // Rendered by `admin/mfa_handlers.rs` (enrol / verify /
454    // regenerate / disable). Same shape of omission as the R2 set
455    // above; same regression-gate test covers them.
456    (
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    /// Every embedded template is registered. Catches typos in
525    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
526    #[test]
527    fn every_embedded_template_loads() {
528        let t = Templates::new(None).unwrap();
529        for (name, _) in EMBEDDED_TEMPLATES {
530            // Render with an empty serializable; minijinja's
531            // strict-undefined fails on missing variables, so most
532            // pages will Err — but parsing happens before evaluation.
533            // We accept any Err whose underlying minijinja error is a
534            // template-evaluation problem; an Error::Internal that
535            // says "template <name> not found" would mean the loader
536            // failed entirely (regression).
537            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    /// Regression gate for the 0.7.0 → 0.7.1 fix.
546    ///
547    /// Scans every `.rs` file under `src/admin/` for string
548    /// literals of the shape `"admin/<name>.html"` and asserts each
549    /// one resolves via `Templates::new(None)?`. If a handler is
550    /// added that renders a new template and the author forgets to
551    /// extend `EMBEDDED_TEMPLATES`, this test fails before the
552    /// release ships rather than after the first user clicks the
553    /// new page.
554    ///
555    /// The 0.6.0 R2 + 0.7.0 R3 cycles both shipped with this
556    /// shape of bug — the disk template files were committed, the
557    /// handlers rendered them, the bug was invisible to unit tests
558    /// (no integration test boots a real HTTP stack against the
559    /// affected routes), and the regression surfaced only when the
560    /// flagship downstream walked the surface against a live DB.
561    /// This test makes the discipline mechanical.
562    #[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        // Bare-literal scan — the framework only uses
567        // `"admin/<...>.html"` strings as template names, so
568        // finding every literal of that shape catches the entire
569        // surface without needing AST parsing or a regex
570        // dependency.
571        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                // `not found` is minijinja's "template not in
588                // loader" error. Other errors (strict-undefined,
589                // type mismatches) are tolerated — the test cares
590                // only about loader resolution, not full render
591                // success against an empty context.
592                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    /// Pull every `"admin/<...>.html"` literal out of `content` and
605    /// stuff it into `out`. Bare-string scan; tolerates string
606    /// literals that span multiple lines because the closing
607    /// `.html"` must appear before the next double-quote.
608    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; // past the opening quote
613            let after = &content[start..];
614            // The literal ends at the next `"`. If `.html` does
615            // not appear before that quote, this isn't a template
616            // reference (it could be e.g. a Permission action_name
617            // that happens to start with `admin/`).
618            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    /// Recursively walk every `.rs` file under `root`, calling
631    /// `visit` for each. Std-only — no `walkdir` dep needed for a
632    /// single test.
633    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}