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 axum::{
2 extract::{Path, State},
3 http::header,
4 response::{IntoResponse, Response},
5 routing::get,
6 Router,
7};
8
9use crate::render::{not_found, render};
10use crate::AppState;
11
12pub fn router() -> Router<AppState> {
13 Router::new()
14 .route("/{name}/blob/{rev}/{*path}", get(blob_page))
15 .route("/{name}/raw/{rev}/{*path}", get(blob_raw))
16}
17
18async fn blob_page(
19 Path((name, rev, path)): Path<(String, String, String)>,
20 State(state): State<AppState>,
21) -> Response {
22 let repo_root = state.config.repo_root.clone();
23 let clone_base = state.config.clone_base.clone();
24 let name_for_blocking = name.clone();
25 let rev_for_blocking = rev.clone();
26 let path_for_blocking = path.clone();
27 let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
28 let repo = crate::git::open(&repo_root, &name_for_blocking)?;
29 let summary = crate::git::repo_summary(
30 &crate::git::resolve_path(&repo_root, &name_for_blocking)?,
31 &clone_base,
32 )?;
33 let oid = crate::git::resolve_rev(&repo, &rev_for_blocking)?;
34 let blob = crate::git::read_blob(&repo, oid, &path_for_blocking)?;
35 Ok((summary, blob))
36 })
37 .await;
38 match result {
39 Ok(Ok((summary, blob))) => {
40 let basename = path.rsplit('/').next().unwrap_or(&path).to_string();
41 // Syntect on multi-MB sources is seconds of CPU per request;
42 // past this cap the page offers the raw link instead.
43 const HIGHLIGHT_CAP: usize = 1024 * 1024;
44 let too_large = !blob.is_binary && blob.data.len() > HIGHLIGHT_CAP;
45 let highlighted = if blob.is_binary || too_large {
46 None
47 } else {
48 let text = String::from_utf8_lossy(&blob.data).into_owned();
49 Some(crate::highlight::highlight(&text, &basename))
50 };
51 render(
52 &state,
53 "blob.html",
54 &format!("/{name}/blob/{rev}/{path}"),
55 minijinja::context! {
56 repo => &summary,
57 rev => &rev,
58 path => &path,
59 basename => basename,
60 size => blob.size,
61 is_binary => blob.is_binary,
62 too_large => too_large,
63 highlighted => highlighted.map(minijinja::Value::from_safe_string),
64 },
65 )
66 }
67 Ok(Err(e)) => not_found(&state, &name, e),
68 Err(e) => {
69 tracing::error!("blob_page join: {e}");
70 (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()
71 }
72 }
73}
74
75async fn blob_raw(
76 Path((name, rev, path)): Path<(String, String, String)>,
77 State(state): State<AppState>,
78) -> Response {
79 let repo_root = state.config.repo_root.clone();
80 let name_for_blocking = name.clone();
81 let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
82 let repo = crate::git::open(&repo_root, &name_for_blocking)?;
83 let oid = crate::git::resolve_rev(&repo, &rev)?;
84 let blob = crate::git::read_blob(&repo, oid, &path)?;
85 let mime = mime_guess::from_path(&path)
86 .first_or_octet_stream()
87 .essence_str()
88 .to_string();
89 Ok((blob.data, mime))
90 })
91 .await;
92 match result {
93 Ok(Ok((data, mime))) => {
94 // Never serve repo content as an active type: an HTML or SVG
95 // blob rendered inline would run its scripts on this origin
96 // (stored XSS from any pushed file).
97 let mime = match mime.as_str() {
98 "text/html" | "application/xhtml+xml" | "image/svg+xml" | "text/xml"
99 | "application/xml" => "text/plain".to_string(),
100 _ => mime,
101 };
102 let mut resp = data.into_response();
103 let value = mime
104 .parse()
105 .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
106 resp.headers_mut().insert(header::CONTENT_TYPE, value);
107 resp.headers_mut().insert(
108 header::X_CONTENT_TYPE_OPTIONS,
109 header::HeaderValue::from_static("nosniff"),
110 );
111 resp
112 }
113 Ok(Err(e)) => not_found(&state, &name, e),
114 Err(e) => {
115 tracing::error!("blob_raw join: {e}");
116 (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error").into_response()
117 }
118 }
119}