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::path::{Path, PathBuf};
14use std::sync::{Arc, OnceLock};
15
16use minijinja::{Environment, ErrorKind};
17
18/// Process-wide admin template environment. Built once on first
19/// access from [`TemplatingConfig::default()`]; auto-reload is
20/// enabled under `debug_assertions`, so template edits are picked up
21/// without a restart in development.
22pub fn env() -> Arc<Environment<'static>> {
23    static CELL: OnceLock<Arc<Environment<'static>>> = OnceLock::new();
24    CELL.get_or_init(|| Arc::new(environment(&TemplatingConfig::default())))
25        .clone()
26}
27
28/// Runtime configuration for the admin template environment.
29#[derive(Clone, Debug)]
30pub struct TemplatingConfig {
31    /// Directory scanned for per-project admin template overrides. When
32    /// `None`, only the embedded defaults are served.
33    pub overrides_root: Option<PathBuf>,
34    /// Re-read templates on every render. Intended for development; keep
35    /// off in release to avoid per-request filesystem work.
36    pub auto_reload: bool,
37}
38
39impl Default for TemplatingConfig {
40    fn default() -> Self {
41        let overrides_root = std::env::current_dir()
42            .ok()
43            .map(|cwd| cwd.join("templates"))
44            .filter(|p| p.is_dir());
45        Self {
46            overrides_root,
47            auto_reload: cfg!(debug_assertions),
48        }
49    }
50}
51
52/// Construct a fresh `minijinja::Environment` with the framework's
53/// embedded templates pre-registered and an optional filesystem override
54/// root. Callers typically wrap the result in `Arc` and cache it.
55pub fn environment(config: &TemplatingConfig) -> Environment<'static> {
56    let mut env = Environment::new();
57    env.set_auto_escape_callback(|name| {
58        if name.ends_with(".html") || name.ends_with(".htm") {
59            minijinja::AutoEscape::Html
60        } else {
61            minijinja::AutoEscape::None
62        }
63    });
64
65    let overrides_root: Option<Arc<Path>> = config.overrides_root.clone().map(Into::into);
66    let auto_reload = config.auto_reload;
67
68    if auto_reload {
69        let overrides_root = overrides_root.clone();
70        env.set_loader(move |name| load_template(name, overrides_root.as_deref()));
71    } else {
72        let resolved = resolve_all(overrides_root.as_deref());
73        env.set_loader(move |name| Ok(resolved.lookup(name).map(|s| s.to_string())));
74    }
75    env
76}
77
78fn load_template(
79    name: &str,
80    overrides_root: Option<&Path>,
81) -> Result<Option<String>, minijinja::Error> {
82    if let Some(root) = overrides_root {
83        match read_override(root, name) {
84            Ok(Some(source)) => return Ok(Some(source)),
85            Ok(None) => {}
86            Err(err) => {
87                return Err(minijinja::Error::new(
88                    ErrorKind::InvalidOperation,
89                    format!("reading override template {name}: {err}"),
90                ));
91            }
92        }
93    }
94    Ok(embedded(name).map(str::to_string))
95}
96
97fn read_override(root: &Path, name: &str) -> std::io::Result<Option<String>> {
98    let path = safe_join(root, name);
99    if !path.is_file() {
100        return Ok(None);
101    }
102    std::fs::read_to_string(path).map(Some)
103}
104
105/// Reject traversal attempts. Template names are framework-controlled, but
106/// treating them defensively keeps future callers honest.
107fn safe_join(root: &Path, name: &str) -> PathBuf {
108    let mut out = root.to_path_buf();
109    for segment in name.split('/') {
110        if segment.is_empty() || segment == "." || segment == ".." {
111            continue;
112        }
113        out.push(segment);
114    }
115    out
116}
117
118struct ResolvedSet {
119    cache: std::collections::HashMap<&'static str, String>,
120}
121
122impl ResolvedSet {
123    fn lookup(&self, name: &str) -> Option<&str> {
124        if let Some(s) = self.cache.get(name) {
125            return Some(s.as_str());
126        }
127        // Fallback to embedded directly — callers outside the known set
128        // still work, just without override support after snapshot.
129        embedded(name)
130    }
131}
132
133fn resolve_all(overrides_root: Option<&Path>) -> ResolvedSet {
134    let mut cache = std::collections::HashMap::with_capacity(EMBEDDED.len());
135    for (name, default_source) in EMBEDDED {
136        let source = overrides_root
137            .and_then(|root| std::fs::read_to_string(safe_join(root, name)).ok())
138            .unwrap_or_else(|| (*default_source).to_string());
139        cache.insert(*name, source);
140    }
141    ResolvedSet { cache }
142}
143
144/// Compile-time bundled defaults. Extend this table when you add a new
145/// framework-owned admin template. The loader also serves names outside
146/// this set via filesystem overrides, so extra user templates keep
147/// working.
148const EMBEDDED: &[(&str, &str)] = &[
149    (
150        "base.html",
151        include_str!("../../assets/templates/base.html"),
152    ),
153    (
154        "base_admin.html",
155        include_str!("../../assets/templates/base_admin.html"),
156    ),
157    (
158        "includes/header.html",
159        include_str!("../../assets/templates/includes/header.html"),
160    ),
161    (
162        "includes/sidebar.html",
163        include_str!("../../assets/templates/includes/sidebar.html"),
164    ),
165    (
166        "includes/footer.html",
167        include_str!("../../assets/templates/includes/footer.html"),
168    ),
169    (
170        "admin/dashboard.html",
171        include_str!("../../assets/templates/admin/dashboard.html"),
172    ),
173    (
174        "admin/list.html",
175        include_str!("../../assets/templates/admin/list.html"),
176    ),
177    (
178        "admin/form.html",
179        include_str!("../../assets/templates/admin/form.html"),
180    ),
181    (
182        "admin/profile.html",
183        include_str!("../../assets/templates/admin/profile.html"),
184    ),
185    (
186        "admin/password_change.html",
187        include_str!("../../assets/templates/admin/password_change.html"),
188    ),
189    (
190        "admin/password_change_done.html",
191        include_str!("../../assets/templates/admin/password_change_done.html"),
192    ),
193    (
194        "admin/actions.html",
195        include_str!("../../assets/templates/admin/actions.html"),
196    ),
197    (
198        "admin/suggestion_review.html",
199        include_str!("../../assets/templates/admin/suggestion_review.html"),
200    ),
201    (
202        "admin/suggestion_applied.html",
203        include_str!("../../assets/templates/admin/suggestion_applied.html"),
204    ),
205    (
206        "auth/login.html",
207        include_str!("../../assets/templates/auth/login.html"),
208    ),
209    (
210        "auth/forbidden.html",
211        include_str!("../../assets/templates/auth/forbidden.html"),
212    ),
213    (
214        "auth/not_found.html",
215        include_str!("../../assets/templates/auth/not_found.html"),
216    ),
217];
218
219fn embedded(name: &str) -> Option<&'static str> {
220    EMBEDDED
221        .iter()
222        .find_map(|(n, s)| if *n == name { Some(*s) } else { None })
223}
224
225/// Framework CSS/JS served under `/admin/static/…`. The tuples are
226/// `(path_under_admin_static, content_type, bytes)`. As of 0.11.x the
227/// admin no longer ships Bootstrap — the design system lives in
228/// `admin.css`, compiled at build time by `build.rs` from the Tailwind
229/// v4 source at `assets/static/admin.css`. The compiled bytes land in
230/// `OUT_DIR/admin.css` and are inlined here via `include_bytes!`.
231pub const BUNDLED_ASSETS: &[(&str, &str, &[u8])] = &[
232    (
233        "admin.css",
234        "text/css; charset=utf-8",
235        include_bytes!(concat!(env!("OUT_DIR"), "/admin.css")),
236    ),
237    (
238        "app.js",
239        "application/javascript; charset=utf-8",
240        include_bytes!("../../assets/static/app.js"),
241    ),
242];
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn every_embedded_template_parses() {
250        let env = environment(&TemplatingConfig {
251            overrides_root: None,
252            auto_reload: false,
253        });
254        for (name, _) in EMBEDDED {
255            env.get_template(name)
256                .unwrap_or_else(|e| panic!("embedded template {name} failed to parse: {e}"));
257        }
258    }
259
260    #[test]
261    fn dashboard_renders_with_minimum_context() {
262        let env = environment(&TemplatingConfig {
263            overrides_root: None,
264            auto_reload: false,
265        });
266        let tmpl = env.get_template("admin/dashboard.html").unwrap();
267        let out = tmpl
268            .render(minijinja::context! {
269                design => minijinja::context! { project_name => "Test" },
270                current_user => minijinja::Value::from(()),
271                sidebar_entries => Vec::<minijinja::Value>::new(),
272                dashboard_cards => Vec::<minijinja::Value>::new(),
273            })
274            .unwrap();
275        // The 0.10.x design system titles the dashboard page
276        // "Your workspace" inside the page-head, and "Overview ·
277        // <project>" in the <title>. Either signals the template
278        // resolved + rendered with the design context dict.
279        assert!(out.contains("Overview"));
280        assert!(out.contains("workspace"));
281        assert!(out.contains("Test"));
282    }
283
284    #[test]
285    fn safe_join_blocks_traversal() {
286        let root = PathBuf::from("/tmp/root");
287        assert_eq!(safe_join(&root, "../etc/passwd"), root.join("etc/passwd"));
288        assert_eq!(safe_join(&root, "./a"), root.join("a"));
289    }
290
291    #[test]
292    fn bundled_assets_are_non_empty() {
293        for (path, _ctype, bytes) in BUNDLED_ASSETS {
294            assert!(!bytes.is_empty(), "bundled asset {path} is empty");
295        }
296    }
297
298    #[test]
299    fn env_accessor_is_cached() {
300        let a = super::env();
301        let b = super::env();
302        assert!(
303            Arc::ptr_eq(&a, &b),
304            "env() should return the same Arc on repeated calls"
305        );
306        a.get_template("base.html").expect("base.html missing");
307    }
308}