use std::collections::HashMap; use std::sync::Arc; use chrono::{DateTime, Utc}; use parking_lot::RwLock; use serde_json::Value; use tera::{Context, Error as TeraError, Tera}; pub type EndpointResolver = Arc) -> Option + Send + Sync>; #[derive(Clone)] pub struct TemplateEngine { tera: Arc>, endpoints: Arc>>, } impl TemplateEngine { pub fn new(template_glob: &str) -> Result { let mut tera = Tera::new(template_glob)?; tera.set_escape_fn(html_escape); register_filters(&mut tera); let endpoints: Arc>> = Arc::new(RwLock::new(HashMap::new())); register_functions(&mut tera, endpoints.clone()); Ok(Self { tera: Arc::new(RwLock::new(tera)), endpoints, }) } pub fn register_endpoint(&self, name: &str, path_template: &str) { self.endpoints .write() .insert(name.to_string(), path_template.to_string()); } pub fn register_endpoints(&self, pairs: &[(&str, &str)]) { let mut guard = self.endpoints.write(); for (n, p) in pairs { guard.insert((*n).to_string(), (*p).to_string()); } } pub fn render(&self, name: &str, context: &Context) -> Result { self.tera.read().render(name, context) } pub fn reload(&self) -> Result<(), TeraError> { self.tera.write().full_reload() } } fn html_escape(input: &str) -> String { let mut out = String::with_capacity(input.len()); for c in input.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } fn register_filters(tera: &mut Tera) { tera.register_filter("format_datetime", format_datetime_filter); tera.register_filter("filesizeformat", filesizeformat_filter); tera.register_filter("slice", slice_filter); } fn register_functions(tera: &mut Tera, endpoints: Arc>>) { let endpoints_for_url = endpoints.clone(); tera.register_function( "url_for", move |args: &HashMap| -> tera::Result { let endpoint = args .get("endpoint") .and_then(|v| v.as_str()) .ok_or_else(|| tera::Error::msg("url_for requires endpoint"))?; if endpoint == "static" { let filename = args.get("filename").and_then(|v| v.as_str()).unwrap_or(""); return Ok(Value::String(format!("/static/{}", filename))); } let path = match endpoints_for_url.read().get(endpoint) { Some(p) => p.clone(), None => { return Ok(Value::String(format!("/__missing__/{}", endpoint))); } }; Ok(Value::String(substitute_path_params(&path, args))) }, ); tera.register_function( "csrf_token", |args: &HashMap| -> tera::Result { if let Some(token) = args.get("token").and_then(|v| v.as_str()) { return Ok(Value::String(token.to_string())); } Ok(Value::String(String::new())) }, ); } fn substitute_path_params(template: &str, args: &HashMap) -> String { let mut path = template.to_string(); let mut query: Vec<(String, String)> = Vec::new(); for (k, v) in args { if k == "endpoint" || k == "filename" { continue; } let value_str = value_to_string(v); let placeholder = format!("{{{}}}", k); if path.contains(&placeholder) { let encoded = urlencode_path(&value_str); path = path.replace(&placeholder, &encoded); } else { query.push((k.clone(), value_str)); } } if !query.is_empty() { let qs: Vec = query .into_iter() .map(|(k, v)| format!("{}={}", urlencode_query(&k), urlencode_query(&v))) .collect(); path.push('?'); path.push_str(&qs.join("&")); } path } fn value_to_string(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), Value::Null => String::new(), other => other.to_string(), } } const UNRESERVED: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.') .remove(b'~'); fn urlencode_path(s: &str) -> String { percent_encoding::utf8_percent_encode(s, UNRESERVED).to_string() } fn urlencode_query(s: &str) -> String { percent_encoding::utf8_percent_encode(s, UNRESERVED).to_string() } fn format_datetime_filter(value: &Value, args: &HashMap) -> tera::Result { let format = args .get("format") .and_then(|v| v.as_str()) .unwrap_or("%Y-%m-%d %H:%M:%S UTC"); let dt: Option> = match value { Value::String(s) => DateTime::parse_from_rfc3339(s) .ok() .map(|d| d.with_timezone(&Utc)) .or_else(|| { DateTime::parse_from_rfc2822(s) .ok() .map(|d| d.with_timezone(&Utc)) }), Value::Number(n) => n.as_f64().and_then(|f| { let secs = f as i64; let nanos = ((f - secs as f64) * 1_000_000_000.0) as u32; DateTime::::from_timestamp(secs, nanos) }), _ => None, }; match dt { Some(d) => Ok(Value::String(d.format(format).to_string())), None => Ok(value.clone()), } } fn slice_filter(value: &Value, args: &HashMap) -> tera::Result { let start = args.get("start").and_then(|v| v.as_i64()).unwrap_or(0); let end = args.get("end").and_then(|v| v.as_i64()); match value { Value::String(s) => { let chars: Vec = s.chars().collect(); let len = chars.len() as i64; let norm = |i: i64| -> usize { if i < 0 { (len + i).max(0) as usize } else { i.min(len) as usize } }; let s_idx = norm(start); let e_idx = match end { Some(e) => norm(e), None => len as usize, }; let e_idx = e_idx.max(s_idx); Ok(Value::String(chars[s_idx..e_idx].iter().collect())) } Value::Array(arr) => { let len = arr.len() as i64; let norm = |i: i64| -> usize { if i < 0 { (len + i).max(0) as usize } else { i.min(len) as usize } }; let s_idx = norm(start); let e_idx = match end { Some(e) => norm(e), None => len as usize, }; let e_idx = e_idx.max(s_idx); Ok(Value::Array(arr[s_idx..e_idx].to_vec())) } Value::Null => Ok(Value::String(String::new())), _ => Err(tera::Error::msg("slice: unsupported value type")), } } fn filesizeformat_filter(value: &Value, _args: &HashMap) -> tera::Result { let bytes = match value { Value::Number(n) => n.as_f64().unwrap_or(0.0), Value::String(s) => s.parse::().unwrap_or(0.0), _ => 0.0, }; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; let mut size = bytes; let mut unit = 0; while size >= 1024.0 && unit < UNITS.len() - 1 { size /= 1024.0; unit += 1; } let formatted = if unit == 0 { format!("{} {}", size as u64, UNITS[unit]) } else { format!("{:.1} {}", size, UNITS[unit]) }; Ok(Value::String(formatted)) } #[cfg(test)] mod tests { use super::*; fn test_engine() -> TemplateEngine { let tmp = tempfile::TempDir::new().unwrap(); let tpl = tmp.path().join("t.html"); std::fs::write(&tpl, "").unwrap(); let glob = format!("{}/*.html", tmp.path().display()); let engine = TemplateEngine::new(&glob).unwrap(); engine.register_endpoints(&[ ("ui.buckets_overview", "/ui/buckets"), ("ui.bucket_detail", "/ui/buckets/{bucket_name}"), ( "ui.abort_multipart_upload", "/ui/buckets/{bucket_name}/multipart/{upload_id}/abort", ), ]); engine } fn render_inline(engine: &TemplateEngine, tpl: &str) -> String { let mut tera = engine.tera.write(); tera.add_raw_template("__inline__", tpl).unwrap(); drop(tera); engine.render("__inline__", &Context::new()).unwrap() } #[test] fn static_url() { let e = test_engine(); let out = render_inline( &e, "{{ url_for(endpoint='static', filename='css/main.css') }}", ); assert_eq!(out, "/static/css/main.css"); } #[test] fn path_param_substitution() { let e = test_engine(); let out = render_inline( &e, "{{ url_for(endpoint='ui.bucket_detail', bucket_name='my-bucket') }}", ); assert_eq!(out, "/ui/buckets/my-bucket"); } #[test] fn extra_args_become_query() { let e = test_engine(); let out = render_inline( &e, "{{ url_for(endpoint='ui.bucket_detail', bucket_name='b', tab='replication') }}", ); assert_eq!(out, "/ui/buckets/b?tab=replication"); } #[test] fn filesizeformat_basic() { let v = filesizeformat_filter(&Value::Number(1024.into()), &HashMap::new()).unwrap(); assert_eq!(v, Value::String("1.0 KB".into())); let v = filesizeformat_filter(&Value::Number(1_048_576.into()), &HashMap::new()).unwrap(); assert_eq!(v, Value::String("1.0 MB".into())); let v = filesizeformat_filter(&Value::Number(500.into()), &HashMap::new()).unwrap(); assert_eq!(v, Value::String("500 B".into())); } #[test] fn project_templates_parse() { let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); path.push("templates"); path.push("*.html"); let glob = path.to_string_lossy().replace('\\', "/"); let engine = TemplateEngine::new(&glob).expect("Tera parse failed"); let names: Vec = engine .tera .read() .get_template_names() .map(|s| s.to_string()) .collect(); assert!( names.len() >= 10, "expected 10+ templates, got {}", names.len() ); } #[test] fn format_datetime_rfc3339() { let v = format_datetime_filter( &Value::String("2024-06-15T12:34:56Z".into()), &HashMap::new(), ) .unwrap(); assert_eq!(v, Value::String("2024-06-15 12:34:56 UTC".into())); } }