1use 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 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 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 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 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 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)
120 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
121 }
122
123 #[allow(dead_code)]
128 pub fn render_for_model<S: Serialize>(
129 &self,
130 model: &str,
131 name: &str,
132 ctx: &S,
133 ) -> Result<String> {
134 let page = name.strip_prefix("admin/").unwrap_or(name);
135 let per_model = format!("admin/{model}/{page}");
136 let mut env = self
137 .env
138 .lock()
139 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
140 env.clear_templates();
141 if let Ok(tmpl) = env.get_template(&per_model) {
142 return tmpl
143 .render(ctx)
144 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
145 }
146 let tmpl = env
147 .get_template(name)
148 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
149 tmpl.render(ctx)
150 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
162pub(crate) enum OverrideValidation {
163 Loaded { name: &'static str, bytes: usize },
166 Suspicious { name: &'static str, bytes: usize },
168 Unreadable { name: &'static str, error: String },
170 OrphanAdminFile { path: String },
174}
175
176pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
183 let mut results = Vec::new();
184 for (name, _embedded) in EMBEDDED_TEMPLATES {
185 let path = disk_root.join(name);
186 if !path.is_file() {
187 continue;
188 }
189 match std::fs::read_to_string(&path) {
190 Ok(body) => {
191 let bytes = body.len();
192 let has_structure = body.contains("{% extends")
193 || body.contains("{% block")
194 || body.contains("<html");
195 if has_structure {
196 results.push(OverrideValidation::Loaded { name, bytes });
197 } else {
198 results.push(OverrideValidation::Suspicious { name, bytes });
199 }
200 }
201 Err(e) => {
202 results.push(OverrideValidation::Unreadable {
203 name,
204 error: e.to_string(),
205 });
206 }
207 }
208 }
209
210 let admin_dir = disk_root.join("admin");
218 if admin_dir.is_dir() {
219 let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
220 .iter()
221 .filter_map(|(n, _)| n.strip_prefix("admin/"))
222 .collect();
223 if let Ok(entries) = std::fs::read_dir(&admin_dir) {
224 let mut files: Vec<_> = entries
228 .filter_map(|e| e.ok())
229 .filter(|e| {
230 e.path()
231 .extension()
232 .and_then(|s| s.to_str())
233 .map(|s| s.eq_ignore_ascii_case("html"))
234 .unwrap_or(false)
235 })
236 .collect();
237 files.sort_by_key(|e| e.file_name());
238 for entry in files {
239 let file_name = entry.file_name();
240 let Some(stem_html) = file_name.to_str() else {
241 continue;
242 };
243 if known.contains(stem_html) {
244 continue;
245 }
246 results.push(OverrideValidation::OrphanAdminFile {
247 path: format!("admin/{stem_html}"),
248 });
249 }
250 }
251 }
252
253 results
254}
255
256fn load_template(
257 disk_root: Option<&std::path::Path>,
258 name: &str,
259) -> std::result::Result<Option<String>, minijinja::Error> {
260 if let Some(root) = disk_root {
261 let path = root.join(name);
262 if path.exists() {
263 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
264 minijinja::Error::new(
265 ErrorKind::InvalidOperation,
266 format!("read template {}: {e}", path.display()),
267 )
268 });
269 }
270 }
271 Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
272 if *n == name {
273 Some((*b).to_string())
274 } else {
275 None
276 }
277 }))
278}
279
280const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
282 (
284 "admin/_base.html",
285 include_str!("../assets/templates/admin/_base.html"),
286 ),
287 (
288 "admin/_topbar.html",
289 include_str!("../assets/templates/admin/_topbar.html"),
290 ),
291 (
292 "admin/_sidebar.html",
293 include_str!("../assets/templates/admin/_sidebar.html"),
294 ),
295 (
296 "admin/_theme.html",
297 include_str!("../assets/templates/admin/_theme.html"),
298 ),
299 (
300 "admin/includes/_form_field.html",
301 include_str!("../assets/templates/admin/includes/_form_field.html"),
302 ),
303 (
304 "admin/includes/_field_errors.html",
305 include_str!("../assets/templates/admin/includes/_field_errors.html"),
306 ),
307 (
309 "admin/login.html",
310 include_str!("../assets/templates/admin/login.html"),
311 ),
312 (
313 "admin/index.html",
314 include_str!("../assets/templates/admin/index.html"),
315 ),
316 (
317 "admin/list.html",
318 include_str!("../assets/templates/admin/list.html"),
319 ),
320 (
321 "admin/form.html",
322 include_str!("../assets/templates/admin/form.html"),
323 ),
324 (
325 "admin/confirm_delete.html",
326 include_str!("../assets/templates/admin/confirm_delete.html"),
327 ),
328 (
329 "admin/error.html",
330 include_str!("../assets/templates/admin/error.html"),
331 ),
332 (
333 "admin/forbidden.html",
334 include_str!("../assets/templates/admin/forbidden.html"),
335 ),
336 (
338 "admin/object_history.html",
339 include_str!("../assets/templates/admin/object_history.html"),
340 ),
341 (
342 "admin/log_entries.html",
343 include_str!("../assets/templates/admin/log_entries.html"),
344 ),
345 (
346 "admin/password_change.html",
347 include_str!("../assets/templates/admin/password_change.html"),
348 ),
349 (
351 "admin/users_list.html",
352 include_str!("../assets/templates/admin/users_list.html"),
353 ),
354 (
355 "admin/user_new.html",
356 include_str!("../assets/templates/admin/user_new.html"),
357 ),
358 (
359 "admin/user_edit.html",
360 include_str!("../assets/templates/admin/user_edit.html"),
361 ),
362 (
363 "admin/user_view.html",
364 include_str!("../assets/templates/admin/user_view.html"),
365 ),
366 (
367 "admin/user_confirm_delete.html",
368 include_str!("../assets/templates/admin/user_confirm_delete.html"),
369 ),
370 (
372 "admin/groups_list.html",
373 include_str!("../assets/templates/admin/groups_list.html"),
374 ),
375 (
376 "admin/group_new.html",
377 include_str!("../assets/templates/admin/group_new.html"),
378 ),
379 (
380 "admin/group_edit.html",
381 include_str!("../assets/templates/admin/group_edit.html"),
382 ),
383 (
384 "admin/group_confirm_delete.html",
385 include_str!("../assets/templates/admin/group_confirm_delete.html"),
386 ),
387];
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use serde::Serialize;
393 use std::io::Write;
394
395 #[derive(Serialize)]
396 struct Empty {}
397
398 fn tempdir() -> std::path::PathBuf {
399 let dir = std::env::temp_dir().join(format!(
400 "rustio-admin-test-{}",
401 std::time::SystemTime::now()
402 .duration_since(std::time::UNIX_EPOCH)
403 .unwrap()
404 .as_nanos()
405 ));
406 std::fs::create_dir_all(&dir).unwrap();
407 dir
408 }
409
410 #[test]
411 fn missing_template_errors_cleanly() {
412 let t = Templates::new(None).unwrap();
413 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
414 assert_eq!(err.status(), 500);
415 }
416
417 #[test]
418 fn disk_loader_finds_project_template() {
419 let dir = tempdir();
420 let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
421 f.write_all(b"hi from disk").unwrap();
422 drop(f);
423
424 let t = Templates::new(Some(dir.clone())).unwrap();
425 let body = t.render("hello.html", &Empty {}).unwrap();
426 assert_eq!(body, "hi from disk");
427
428 let _ = std::fs::remove_dir_all(&dir);
429 }
430
431 #[test]
434 fn every_embedded_template_loads() {
435 let t = Templates::new(None).unwrap();
436 for (name, _) in EMBEDDED_TEMPLATES {
437 let result = t.render(name, &Empty {});
445 if let Err(e) = result {
446 let msg = e.to_string();
447 assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
448 }
449 }
450 }
451}