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];
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use serde::Serialize;
421    use std::io::Write;
422
423    #[derive(Serialize)]
424    struct Empty {}
425
426    fn tempdir() -> std::path::PathBuf {
427        let dir = std::env::temp_dir().join(format!(
428            "rustio-admin-test-{}",
429            std::time::SystemTime::now()
430                .duration_since(std::time::UNIX_EPOCH)
431                .unwrap()
432                .as_nanos()
433        ));
434        std::fs::create_dir_all(&dir).unwrap();
435        dir
436    }
437
438    #[test]
439    fn missing_template_errors_cleanly() {
440        let t = Templates::new(None).unwrap();
441        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
442        assert_eq!(err.status(), 500);
443    }
444
445    #[test]
446    fn disk_loader_finds_project_template() {
447        let dir = tempdir();
448        let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
449        f.write_all(b"hi from disk").unwrap();
450        drop(f);
451
452        let t = Templates::new(Some(dir.clone())).unwrap();
453        let body = t.render("hello.html", &Empty {}).unwrap();
454        assert_eq!(body, "hi from disk");
455
456        let _ = std::fs::remove_dir_all(&dir);
457    }
458
459    /// Every embedded template is registered. Catches typos in
460    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
461    #[test]
462    fn every_embedded_template_loads() {
463        let t = Templates::new(None).unwrap();
464        for (name, _) in EMBEDDED_TEMPLATES {
465            // Render with an empty serializable; minijinja's
466            // strict-undefined fails on missing variables, so most
467            // pages will Err — but parsing happens before evaluation.
468            // We accept any Err whose underlying minijinja error is a
469            // template-evaluation problem; an Error::Internal that
470            // says "template <name> not found" would mean the loader
471            // failed entirely (regression).
472            let result = t.render(name, &Empty {});
473            if let Err(e) = result {
474                let msg = e.to_string();
475                assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
476            }
477        }
478    }
479}