A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.
axumdockergitgit-browsergitoxidegixrustself-hosted
1use minijinja::value::Value;
2use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};
3use serde::Serialize;
4use serde_json::Value as JsonValue;
5use std::path::Path;
6
7/// Jinja2-faithful HTML formatter — does NOT escape `/`, so vite asset URLs
8/// like `/static/base-abc123.js` come through clean instead of `/...`.
9fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
10 if value.is_safe() {
11 write!(out, "{value}").map_err(Error::from)?;
12 return Ok(());
13 }
14 let auto_escape = match state.auto_escape() {
15 AutoEscape::Html => true,
16 AutoEscape::None => false,
17 _ => return minijinja::escape_formatter(out, state, value),
18 };
19 if !auto_escape {
20 write!(out, "{value}").map_err(Error::from)?;
21 return Ok(());
22 }
23 if let Some(s) = value.as_str() {
24 write_jinja2_html(out, s).map_err(Error::from)?;
25 } else if value.is_undefined() || value.is_none() {
26 // emit nothing
27 } else {
28 let stringified = value.to_string();
29 write_jinja2_html(out, &stringified).map_err(Error::from)?;
30 }
31 Ok(())
32}
33
34fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {
35 let mut last = 0;
36 for (i, b) in s.bytes().enumerate() {
37 let escape = match b {
38 b'&' => "&",
39 b'<' => "<",
40 b'>' => ">",
41 b'"' => """,
42 b'\'' => "'",
43 _ => continue,
44 };
45 if last < i {
46 out.write_str(&s[last..i])?;
47 }
48 out.write_str(escape)?;
49 last = i + 1;
50 }
51 if last < s.len() {
52 out.write_str(&s[last..])?;
53 }
54 Ok(())
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct RequestCtx {
59 pub path: String,
60}
61
62fn read_manifest(path: &Path) -> JsonValue {
63 let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
64 serde_json::from_str(&text).unwrap_or(JsonValue::Null)
65}
66
67fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {
68 if let Some(chunk) = manifest.get(entry) {
69 if kind == "css" {
70 if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {
71 if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {
72 return format!("/static/{first}");
73 }
74 }
75 }
76 if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {
77 return format!("/static/{file}");
78 }
79 }
80 format!("/static/{entry}")
81}
82
83pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {
84 let mut env = Environment::new();
85 env.set_loader(path_loader(templates_dir));
86 env.set_formatter(jinja2_html_formatter);
87 // Without this, every {{ ... }} renders raw — minijinja v2 ships no
88 // autoescape by default. The default callback enables HTML escape on
89 // .html/.htm/.xml, which is what we want for templates and atom feeds.
90 env.set_auto_escape_callback(minijinja::default_auto_escape_callback);
91
92 #[cfg(debug_assertions)]
93 {
94 let path = manifest_path.to_path_buf();
95 env.add_function(
96 "vite_asset",
97 move |entry: String, kind: Option<String>| -> Result<String, Error> {
98 let kind = kind.unwrap_or_else(|| "file".to_string());
99 let manifest = read_manifest(&path);
100 Ok(lookup_asset(&manifest, &entry, &kind))
101 },
102 );
103 }
104 #[cfg(not(debug_assertions))]
105 {
106 let manifest = read_manifest(manifest_path);
107 env.add_function(
108 "vite_asset",
109 move |entry: String, kind: Option<String>| -> Result<String, Error> {
110 let kind = kind.unwrap_or_else(|| "file".to_string());
111 Ok(lookup_asset(&manifest, &entry, &kind))
112 },
113 );
114 }
115
116 env.add_filter("shortsha", shortsha_filter);
117 env.add_filter("naturaltime", naturaltime_filter);
118 env.add_filter("rfc3339", rfc3339_filter);
119 env.add_filter("filesize", filesize_filter);
120 env.add_filter("urlencode", urlencode_filter);
121 env.add_filter("urlencode_path", urlencode_path_filter);
122
123 env
124}
125
126fn shortsha_filter(value: Value) -> Result<String, Error> {
127 let s = value
128 .as_str()
129 .map(|s| s.to_string())
130 .unwrap_or_else(|| value.to_string());
131 Ok(s.chars().take(8).collect())
132}
133
134fn rfc3339_filter(value: Value) -> Result<String, Error> {
135 let ts = value.as_i64().ok_or_else(|| {
136 Error::new(ErrorKind::InvalidOperation, "rfc3339 expects an integer")
137 })?;
138 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0).ok_or_else(|| {
139 Error::new(ErrorKind::InvalidOperation, "rfc3339: timestamp out of range")
140 })?;
141 Ok(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
142}
143
144fn urlencode_filter(value: Value) -> Result<String, Error> {
145 let s = value
146 .as_str()
147 .map(|s| s.to_string())
148 .unwrap_or_else(|| value.to_string());
149 Ok(urlencoding::encode(&s).into_owned())
150}
151
152/// Percent-encode each segment of a slash-separated path while keeping the
153/// separators, so tree/blob paths with `#`, `?`, or `%` in a filename still
154/// produce working hrefs.
155fn urlencode_path_filter(value: Value) -> Result<String, Error> {
156 let s = value
157 .as_str()
158 .map(|s| s.to_string())
159 .unwrap_or_else(|| value.to_string());
160 Ok(s.split('/')
161 .map(|seg| urlencoding::encode(seg).into_owned())
162 .collect::<Vec<_>>()
163 .join("/"))
164}
165
166fn filesize_filter(value: Value) -> Result<String, Error> {
167 let bytes = value.as_i64().ok_or_else(|| {
168 Error::new(ErrorKind::InvalidOperation, "filesize expects an integer")
169 })?;
170 let b = bytes as f64;
171 Ok(if b < 1024.0 {
172 format!("{} B", bytes)
173 } else if b < 1024.0 * 1024.0 {
174 format!("{:.1} KB", b / 1024.0)
175 } else if b < 1024.0 * 1024.0 * 1024.0 {
176 format!("{:.1} MB", b / (1024.0 * 1024.0))
177 } else {
178 format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
179 })
180}
181
182/// Render a unix-seconds timestamp as "5 minutes ago" / "3 days ago" / etc.
183fn naturaltime_filter(value: Value) -> Result<String, Error> {
184 let secs_ago = if let Some(ts) = value.as_i64() {
185 chrono::Utc::now().timestamp().saturating_sub(ts)
186 } else {
187 return Ok(value.to_string());
188 };
189 if secs_ago < 0 {
190 return Ok("in the future".to_string());
191 }
192 Ok(if secs_ago < 60 {
193 "just now".to_string()
194 } else if secs_ago < 3600 {
195 let m = secs_ago / 60;
196 format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
197 } else if secs_ago < 86_400 {
198 let h = secs_ago / 3600;
199 format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
200 } else if secs_ago < 86_400 * 30 {
201 let d = secs_ago / 86_400;
202 format!("{d} day{} ago", if d == 1 { "" } else { "s" })
203 } else if secs_ago < 86_400 * 365 {
204 let m = secs_ago / (86_400 * 30);
205 format!("{m} month{} ago", if m == 1 { "" } else { "s" })
206 } else {
207 let y = secs_ago / (86_400 * 365);
208 format!("{y} year{} ago", if y == 1 { "" } else { "s" })
209 })
210}