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

3.3 KB · 112 lines · Rust Raw History
  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