Full migration and transition to Rust; Remove python artifacts

This commit is contained in:
2026-04-22 17:19:19 +08:00
parent 51d54b42ac
commit 217af6d1c6
204 changed files with 30 additions and 54451 deletions

View File

@@ -0,0 +1,355 @@
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<dyn Fn(&str, &HashMap<String, Value>) -> Option<String> + Send + Sync>;
#[derive(Clone)]
pub struct TemplateEngine {
tera: Arc<RwLock<Tera>>,
endpoints: Arc<RwLock<HashMap<String, String>>>,
}
impl TemplateEngine {
pub fn new(template_glob: &str) -> Result<Self, TeraError> {
let mut tera = Tera::new(template_glob)?;
tera.set_escape_fn(html_escape);
register_filters(&mut tera);
let endpoints: Arc<RwLock<HashMap<String, String>>> = 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<String, TeraError> {
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#x27;"),
_ => 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<RwLock<HashMap<String, String>>>) {
let endpoints_for_url = endpoints.clone();
tera.register_function(
"url_for",
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
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<String, Value>| -> tera::Result<Value> {
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, Value>) -> 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<String> = 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<String, Value>) -> tera::Result<Value> {
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%d %H:%M:%S UTC");
let dt: Option<DateTime<Utc>> = 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::<Utc>::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<String, Value>) -> tera::Result<Value> {
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<char> = 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<String, Value>) -> tera::Result<Value> {
let bytes = match value {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(s) => s.parse::<f64>().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<String> = 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()));
}
}