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
1//! Smart-HTTP clone endpoints. Captures any URL whose first segment ends in
2//! `.git`, hands the rest off to `git http-backend` as PATH_INFO.
3
4use axum::{
5 extract::{ConnectInfo, Path, Request, State},
6 http::StatusCode,
7 response::{IntoResponse, Response},
8 routing::{any, get, post},
9 Router,
10};
11use std::net::SocketAddr;
12
13use crate::http_backend::{self, CgiRequest};
14use crate::AppState;
15
16pub fn router() -> Router<AppState> {
17 Router::new()
18 .route("/{name_git}/info/refs", get(info_refs))
19 .route("/{name_git}/git-upload-pack", post(upload_pack))
20 // git push would be /git-receive-pack; we expose it as a 405 so the
21 // remote prints a helpful error rather than a generic 404.
22 .route("/{name_git}/git-receive-pack", any(receive_pack_forbidden))
23}
24
25fn strip_git_suffix(name_git: &str) -> Option<&str> {
26 name_git.strip_suffix(".git")
27}
28
29fn validate_name(name: &str) -> Result<(), Response> {
30 if name.is_empty()
31 || name.contains('/')
32 || name.contains('\\')
33 || name.starts_with('.')
34 || name.contains("..")
35 {
36 Err((StatusCode::NOT_FOUND, "no such repo").into_response())
37 } else {
38 Ok(())
39 }
40}
41
42async fn info_refs(
43 Path(name_git): Path<String>,
44 State(state): State<AppState>,
45 ConnectInfo(addr): ConnectInfo<SocketAddr>,
46 req: Request,
47) -> Response {
48 let Some(name) = strip_git_suffix(&name_git) else {
49 return (StatusCode::NOT_FOUND, "no such repo").into_response();
50 };
51 if let Err(r) = validate_name(name) {
52 return r;
53 }
54 // Bare repos live as /srv/git/<name>.git/. We hand the parent dir to
55 // git http-backend as GIT_PROJECT_ROOT and set PATH_INFO to the rest.
56 let path_info = format!("/{name}.git/info/refs");
57 serve(state, req, addr, path_info).await
58}
59
60async fn upload_pack(
61 Path(name_git): Path<String>,
62 State(state): State<AppState>,
63 ConnectInfo(addr): ConnectInfo<SocketAddr>,
64 req: Request,
65) -> Response {
66 let Some(name) = strip_git_suffix(&name_git) else {
67 return (StatusCode::NOT_FOUND, "no such repo").into_response();
68 };
69 if let Err(r) = validate_name(name) {
70 return r;
71 }
72 let path_info = format!("/{name}.git/git-upload-pack");
73 serve(state, req, addr, path_info).await
74}
75
76async fn receive_pack_forbidden() -> Response {
77 (
78 StatusCode::METHOD_NOT_ALLOWED,
79 "repos is read-only; push to the server's git remote directly",
80 )
81 .into_response()
82}
83
84async fn serve(state: AppState, req: Request, addr: SocketAddr, path_info: String) -> Response {
85 let (parts, body) = req.into_parts();
86 let query = http_backend::extract_query(&parts.uri);
87 let content_type = parts
88 .headers
89 .get(axum::http::header::CONTENT_TYPE)
90 .and_then(|v| v.to_str().ok())
91 .map(|s| s.to_string());
92 let method = parts.method.clone();
93
94 let cgi = CgiRequest {
95 repo_root: &state.config.repo_root,
96 method,
97 path_info,
98 query,
99 content_type,
100 remote_addr: addr.ip().to_string(),
101 headers: parts.headers,
102 };
103 match http_backend::serve(cgi, body).await {
104 Ok(resp) => resp,
105 Err(e) => {
106 tracing::error!("http-backend: {e:#}");
107 (StatusCode::BAD_GATEWAY, "clone unavailable").into_response()
108 }
109 }
110}
111