repos
/ repos-rust master

repos-rust

mirror archived upstream

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

7.1 KB · 211 lines · Rust Raw History
  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'&' => "&amp;",
 39            b'<' => "&lt;",
 40            b'>' => "&gt;",
 41            b'"' => "&#34;",
 42            b'\'' => "&#39;",
 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}