Skip to main content

rustio_core/admin/
templating.rs

1//! Admin UI template engine.
2//!
3//! Stage 2 scaffolding for the 0.10.0 admin rebuild. The admin is rendered
4//! by `minijinja` against templates shipped at
5//! `rustio-core/assets/templates/`, bundled into the binary via
6//! `include_str!`. User projects may override any template by placing a
7//! file of the same relative path under their project's `templates/`
8//! directory — the loader chain is filesystem-first, embedded-fallback.
9//!
10//! Stage 4a onwards consumes [`env()`] — the process-wide
11//! `Arc<Environment>` shared by every admin handler.
12
13use std::fmt;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, OnceLock};
16
17use minijinja::{
18    escape_formatter, AutoEscape, Environment, Error, ErrorKind, Output, State, Value,
19};
20
21/// Process-wide admin template environment. Built once on first
22/// access from [`TemplatingConfig::default()`]; auto-reload is
23/// enabled under `debug_assertions`, so template edits are picked up
24/// without a restart in development.
25pub fn env() -> Arc<Environment<'static>> {
26    static CELL: OnceLock<Arc<Environment<'static>>> = OnceLock::new();
27    CELL.get_or_init(|| Arc::new(environment(&TemplatingConfig::default())))
28        .clone()
29}
30
31/// Runtime configuration for the admin template environment.
32#[derive(Clone, Debug)]
33pub struct TemplatingConfig {
34    /// Directory scanned for per-project admin template overrides. When
35    /// `None`, only the embedded defaults are served.
36    pub overrides_root: Option<PathBuf>,
37    /// Re-read templates on every render. Intended for development; keep
38    /// off in release to avoid per-request filesystem work.
39    pub auto_reload: bool,
40}
41
42impl Default for TemplatingConfig {
43    fn default() -> Self {
44        let overrides_root = std::env::current_dir()
45            .ok()
46            .map(|cwd| cwd.join("templates"))
47            .filter(|p| p.is_dir());
48        Self {
49            overrides_root,
50            auto_reload: cfg!(debug_assertions),
51        }
52    }
53}
54
55/// Construct a fresh `minijinja::Environment` with the framework's
56/// embedded templates pre-registered and an optional filesystem override
57/// root. Callers typically wrap the result in `Arc` and cache it.
58pub fn environment(config: &TemplatingConfig) -> Environment<'static> {
59    let mut env = Environment::new();
60    env.set_auto_escape_callback(|name| {
61        if name.ends_with(".html") || name.ends_with(".htm") {
62            minijinja::AutoEscape::Html
63        } else {
64            minijinja::AutoEscape::None
65        }
66    });
67    // Use the Jinja2/Django-standard HTML escape set (no `/`). minijinja's
68    // built-in escaper also turns every `/` into `&#x2f;`, which rendered
69    // every templated URL as `href="&#x2f;admin&#x2f;…"` across all admin
70    // pages — valid (browsers decode it) but noisy, non-standard HTML.
71    env.set_formatter(admin_html_formatter);
72
73    let overrides_root: Option<Arc<Path>> = config.overrides_root.clone().map(Into::into);
74    let auto_reload = config.auto_reload;
75
76    if auto_reload {
77        let overrides_root = overrides_root.clone();
78        env.set_loader(move |name| load_template(name, overrides_root.as_deref()));
79    } else {
80        let resolved = resolve_all(overrides_root.as_deref());
81        env.set_loader(move |name| Ok(resolved.lookup(name).map(|s| s.to_string())));
82    }
83    env
84}
85
86/// Minijinja formatter for the admin templates. Identical to the
87/// default ([`escape_formatter`]) except the HTML auto-escape path uses
88/// the Jinja2/Django character set ([`HtmlEscapeNoSlash`]) instead of
89/// minijinja's built-in one, which additionally escapes `/`. Safe
90/// strings (`{{ x | safe }}`) and every non-HTML autoescape mode are
91/// delegated unchanged, so escaping of the XSS-relevant characters
92/// (`<`, `>`, `&`, `"`, `'`) is untouched.
93fn admin_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
94    if matches!(state.auto_escape(), AutoEscape::Html) && !value.is_safe() {
95        return write!(out, "{}", HtmlEscapeNoSlash(&value.to_string())).map_err(Error::from);
96    }
97    escape_formatter(out, state, value)
98}
99
100/// HTML escaper matching the Jinja2/Django convention: escapes `<`, `>`,
101/// `&`, `"`, and `'`, but leaves `/` alone. Slash is not a dangerous
102/// character in HTML text or attribute contexts, so omitting it is safe
103/// and keeps emitted URLs readable (`/admin/customers`, not
104/// `&#x2f;admin&#x2f;customers`).
105struct HtmlEscapeNoSlash<'a>(&'a str);
106
107impl fmt::Display for HtmlEscapeNoSlash<'_> {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        let s = self.0;
110        let mut start = 0;
111        for (i, b) in s.bytes().enumerate() {
112            let replacement = match b {
113                b'<' => "&lt;",
114                b'>' => "&gt;",
115                b'&' => "&amp;",
116                b'"' => "&quot;",
117                b'\'' => "&#x27;",
118                _ => continue,
119            };
120            if start < i {
121                f.write_str(&s[start..i])?;
122            }
123            f.write_str(replacement)?;
124            start = i + 1;
125        }
126        if start < s.len() {
127            f.write_str(&s[start..])?;
128        }
129        Ok(())
130    }
131}
132
133fn load_template(
134    name: &str,
135    overrides_root: Option<&Path>,
136) -> Result<Option<String>, minijinja::Error> {
137    if let Some(root) = overrides_root {
138        match read_override(root, name) {
139            Ok(Some(source)) => return Ok(Some(source)),
140            Ok(None) => {}
141            Err(err) => {
142                return Err(minijinja::Error::new(
143                    ErrorKind::InvalidOperation,
144                    format!("reading override template {name}: {err}"),
145                ));
146            }
147        }
148    }
149    Ok(embedded(name).map(str::to_string))
150}
151
152fn read_override(root: &Path, name: &str) -> std::io::Result<Option<String>> {
153    let path = safe_join(root, name);
154    if !path.is_file() {
155        return Ok(None);
156    }
157    std::fs::read_to_string(path).map(Some)
158}
159
160/// Reject traversal attempts. Template names are framework-controlled, but
161/// treating them defensively keeps future callers honest.
162fn safe_join(root: &Path, name: &str) -> PathBuf {
163    let mut out = root.to_path_buf();
164    for segment in name.split('/') {
165        if segment.is_empty() || segment == "." || segment == ".." {
166            continue;
167        }
168        out.push(segment);
169    }
170    out
171}
172
173struct ResolvedSet {
174    cache: std::collections::HashMap<&'static str, String>,
175}
176
177impl ResolvedSet {
178    fn lookup(&self, name: &str) -> Option<&str> {
179        if let Some(s) = self.cache.get(name) {
180            return Some(s.as_str());
181        }
182        // Fallback to embedded directly — callers outside the known set
183        // still work, just without override support after snapshot.
184        embedded(name)
185    }
186}
187
188fn resolve_all(overrides_root: Option<&Path>) -> ResolvedSet {
189    let mut cache = std::collections::HashMap::with_capacity(EMBEDDED.len());
190    for (name, default_source) in EMBEDDED {
191        let source = overrides_root
192            .and_then(|root| std::fs::read_to_string(safe_join(root, name)).ok())
193            .unwrap_or_else(|| (*default_source).to_string());
194        cache.insert(*name, source);
195    }
196    ResolvedSet { cache }
197}
198
199/// Compile-time bundled defaults. Extend this table when you add a new
200/// framework-owned admin template. The loader also serves names outside
201/// this set via filesystem overrides, so extra user templates keep
202/// working.
203const EMBEDDED: &[(&str, &str)] = &[
204    (
205        "base.html",
206        include_str!("../../assets/templates/base.html"),
207    ),
208    (
209        "base_admin.html",
210        include_str!("../../assets/templates/base_admin.html"),
211    ),
212    (
213        "includes/header.html",
214        include_str!("../../assets/templates/includes/header.html"),
215    ),
216    (
217        "includes/sidebar.html",
218        include_str!("../../assets/templates/includes/sidebar.html"),
219    ),
220    (
221        "includes/footer.html",
222        include_str!("../../assets/templates/includes/footer.html"),
223    ),
224    (
225        "admin/dashboard.html",
226        include_str!("../../assets/templates/admin/dashboard.html"),
227    ),
228    (
229        "admin/list.html",
230        include_str!("../../assets/templates/admin/list.html"),
231    ),
232    (
233        "admin/form.html",
234        include_str!("../../assets/templates/admin/form.html"),
235    ),
236    (
237        "admin/profile.html",
238        include_str!("../../assets/templates/admin/profile.html"),
239    ),
240    (
241        "admin/password_change.html",
242        include_str!("../../assets/templates/admin/password_change.html"),
243    ),
244    (
245        "admin/password_change_done.html",
246        include_str!("../../assets/templates/admin/password_change_done.html"),
247    ),
248    (
249        "admin/actions.html",
250        include_str!("../../assets/templates/admin/actions.html"),
251    ),
252    (
253        "admin/suggestion_review.html",
254        include_str!("../../assets/templates/admin/suggestion_review.html"),
255    ),
256    (
257        "admin/suggestion_applied.html",
258        include_str!("../../assets/templates/admin/suggestion_applied.html"),
259    ),
260    (
261        "auth/login.html",
262        include_str!("../../assets/templates/auth/login.html"),
263    ),
264    (
265        "auth/forbidden.html",
266        include_str!("../../assets/templates/auth/forbidden.html"),
267    ),
268    (
269        "auth/not_found.html",
270        include_str!("../../assets/templates/auth/not_found.html"),
271    ),
272];
273
274fn embedded(name: &str) -> Option<&'static str> {
275    EMBEDDED
276        .iter()
277        .find_map(|(n, s)| if *n == name { Some(*s) } else { None })
278}
279
280/// Framework CSS/JS served under `/admin/static/…`. The tuples are
281/// `(path_under_admin_static, content_type, bytes)`. As of 0.11.x the
282/// admin no longer ships Bootstrap — the design system lives in
283/// `admin.css`, compiled at build time by `build.rs` from the Tailwind
284/// v4 source at `assets/static/admin.css`. The compiled bytes land in
285/// `OUT_DIR/admin.css` and are inlined here via `include_bytes!`.
286pub const BUNDLED_ASSETS: &[(&str, &str, &[u8])] = &[
287    (
288        "admin.css",
289        "text/css; charset=utf-8",
290        include_bytes!(concat!(env!("OUT_DIR"), "/admin.css")),
291    ),
292    (
293        "app.js",
294        "application/javascript; charset=utf-8",
295        include_bytes!("../../assets/static/app.js"),
296    ),
297];
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn every_embedded_template_parses() {
305        let env = environment(&TemplatingConfig {
306            overrides_root: None,
307            auto_reload: false,
308        });
309        for (name, _) in EMBEDDED {
310            env.get_template(name)
311                .unwrap_or_else(|e| panic!("embedded template {name} failed to parse: {e}"));
312        }
313    }
314
315    #[test]
316    fn html_escape_no_slash_escapes_markup_but_keeps_slash() {
317        let out = HtmlEscapeNoSlash("/a/b <x> & \"q\" 'p'").to_string();
318        assert_eq!(out, "/a/b &lt;x&gt; &amp; &quot;q&quot; &#x27;p&#x27;");
319    }
320
321    #[test]
322    fn admin_formatter_keeps_url_slashes_but_escapes_dangerous_chars() {
323        let env = environment(&TemplatingConfig {
324            overrides_root: None,
325            auto_reload: false,
326        });
327        // A `.html` name triggers HTML auto-escape, which routes through
328        // `admin_html_formatter`. URLs must come out with literal slashes…
329        let url = env
330            .render_named_str(
331                "probe.html",
332                "{{ url }}",
333                minijinja::context! { url => "/admin/customers/50/edit" },
334            )
335            .unwrap();
336        assert_eq!(url, "/admin/customers/50/edit");
337        // …while the XSS-relevant characters are still escaped.
338        let danger = env
339            .render_named_str(
340                "probe.html",
341                "{{ s }}",
342                minijinja::context! { s => "<script>&'\"" },
343            )
344            .unwrap();
345        assert_eq!(danger, "&lt;script&gt;&amp;&#x27;&quot;");
346    }
347
348    #[test]
349    fn dashboard_renders_with_minimum_context() {
350        let env = environment(&TemplatingConfig {
351            overrides_root: None,
352            auto_reload: false,
353        });
354        let tmpl = env.get_template("admin/dashboard.html").unwrap();
355        let out = tmpl
356            .render(minijinja::context! {
357                design => minijinja::context! { project_name => "Test" },
358                current_user => minijinja::Value::from(()),
359                sidebar_entries => Vec::<minijinja::Value>::new(),
360                dashboard_cards => Vec::<minijinja::Value>::new(),
361            })
362            .unwrap();
363        // The 0.10.x design system titles the dashboard page
364        // "Your workspace" inside the page-head, and "Overview ·
365        // <project>" in the <title>. Either signals the template
366        // resolved + rendered with the design context dict.
367        assert!(out.contains("Overview"));
368        assert!(out.contains("workspace"));
369        assert!(out.contains("Test"));
370    }
371
372    #[test]
373    fn safe_join_blocks_traversal() {
374        let root = PathBuf::from("/tmp/root");
375        assert_eq!(safe_join(&root, "../etc/passwd"), root.join("etc/passwd"));
376        assert_eq!(safe_join(&root, "./a"), root.join("a"));
377    }
378
379    #[test]
380    fn bundled_assets_are_non_empty() {
381        for (path, _ctype, bytes) in BUNDLED_ASSETS {
382            assert!(!bytes.is_empty(), "bundled asset {path} is empty");
383        }
384    }
385
386    #[test]
387    fn env_accessor_is_cached() {
388        let a = super::env();
389        let b = super::env();
390        assert!(
391            Arc::ptr_eq(&a, &b),
392            "env() should return the same Arc on repeated calls"
393        );
394        a.get_template("base.html").expect("base.html missing");
395    }
396}