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).map_err(|e| {
120 log::error!("template render failed for {name}: {e:?}");
121 Error::Internal(format!("render {name}: {e}"))
122 })
123 }
124
125 #[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#[derive(Debug, Clone, PartialEq, Eq)]
164pub(crate) enum OverrideValidation {
165 Loaded { name: &'static str, bytes: usize },
168 Suspicious { name: &'static str, bytes: usize },
170 Unreadable { name: &'static str, error: String },
172 OrphanAdminFile { path: String },
176}
177
178pub(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 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 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
282const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
284 (
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 (
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 (
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 (
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 (
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 (
399 "admin/account_sessions.html",
400 include_str!("../assets/templates/admin/account_sessions.html"),
401 ),
402 (
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 #[test]
462 fn every_embedded_template_loads() {
463 let t = Templates::new(None).unwrap();
464 for (name, _) in EMBEDDED_TEMPLATES {
465 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}