1use 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
21pub 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#[derive(Clone, Debug)]
33pub struct TemplatingConfig {
34 pub overrides_root: Option<PathBuf>,
37 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
55pub 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 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
86fn 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
100struct 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'<' => "<",
114 b'>' => ">",
115 b'&' => "&",
116 b'"' => """,
117 b'\'' => "'",
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
160fn 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 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
199const 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
280pub 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 <x> & "q" 'p'");
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 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 let danger = env
339 .render_named_str(
340 "probe.html",
341 "{{ s }}",
342 minijinja::context! { s => "<script>&'\"" },
343 )
344 .unwrap();
345 assert_eq!(danger, "<script>&'"");
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 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}