🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / git_http.rs

use crate::auth::{self, User};
use crate::repos::{self, Repo};
use crate::{ci, AppState};
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use std::io::Write;
use std::process::{Command, Stdio};

const REALM: &str = "Okibi — atsm members only";

fn need_auth() -> Response {
    (
        StatusCode::UNAUTHORIZED,
        [(header::WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
        "authentication required\n",
    )
        .into_response()
}

/// Resolve (owner_handle, repo_name) → repo + owner, or an error response.
async fn resolve(
    state: &AppState,
    owner: &str,
    repo_seg: &str,
) -> Result<(User, Repo), Response> {
    let name = repo_seg.strip_suffix(".git").unwrap_or(repo_seg);
    let owner_user = auth::user_by_handle(&state.db, owner)
        .await
        .ok_or_else(|| (StatusCode::NOT_FOUND, "no such owner\n").into_response())?;
    let repo = repos::get_repo(&state.db, owner_user.id, name)
        .await
        .ok_or_else(|| (StatusCode::NOT_FOUND, "no such repo\n").into_response())?;
    Ok((owner_user, repo))
}

/// Authorize a git op. `write` = push (receive-pack). Returns the actor on success.
async fn authorize(
    state: &AppState,
    headers: &HeaderMap,
    owner: &User,
    repo: &Repo,
    write: bool,
) -> Result<Option<User>, Response> {
    // Try PAT basic auth if present.
    let actor = if let Some((u, p)) = auth::basic_auth(headers) {
        auth::verify_pat(&state.db, &u, &p).await
    } else {
        None
    };

    if write {
        // Push: must be authenticated and own the repo.
        match &actor {
            Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
            Some(_) => Err((StatusCode::FORBIDDEN, "not your repo\n").into_response()),
            None => Err(need_auth()),
        }
    } else {
        // Pull: public repos are open; private requires the owner.
        if repo.is_public() {
            Ok(actor)
        } else {
            match &actor {
                Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
                Some(_) => Err((StatusCode::FORBIDDEN, "private repo\n").into_response()),
                None => Err(need_auth()),
            }
        }
    }
}

fn service_is_write(service: &str) -> bool {
    service == "git-receive-pack"
}

// GET /{owner}/{repo}/info/refs?service=git-(upload|receive)-pack
pub async fn info_refs(
    State(state): State<AppState>,
    Path((owner, repo)): Path<(String, String)>,
    RawQuery(query): RawQuery,
    headers: HeaderMap,
) -> Response {
    let query = query.unwrap_or_default();
    let service = query
        .split('&')
        .find_map(|kv| kv.strip_prefix("service="))
        .unwrap_or("")
        .to_string();
    if !service.starts_with("git-") {
        return (StatusCode::BAD_REQUEST, "dumb http not supported\n").into_response();
    }
    let (owner_user, repo_meta) = match resolve(&state, &owner, &repo).await {
        Ok(v) => v,
        Err(r) => return r,
    };
    if let Err(r) = authorize(&state, &headers, &owner_user, &repo_meta, service_is_write(&service)).await {
        return r;
    }
    cgi(
        &state,
        &owner_user.handle,
        &repo,
        "info/refs",
        "GET",
        &query,
        None,
        &headers,
        Bytes::new(),
        None,
    )
    .await
}

// POST /{owner}/{repo}/git-upload-pack
pub async fn upload_pack(
    State(state): State<AppState>,
    Path((owner, repo)): Path<(String, String)>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    service(&state, owner, repo, "git-upload-pack", false, headers, body).await
}

// POST /{owner}/{repo}/git-receive-pack
pub async fn receive_pack(
    State(state): State<AppState>,
    Path((owner, repo)): Path<(String, String)>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    service(&state, owner, repo, "git-receive-pack", true, headers, body).await
}

async fn service(
    state: &AppState,
    owner: String,
    repo: String,
    svc: &str,
    write: bool,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let (owner_user, repo_meta) = match resolve(state, &owner, &repo).await {
        Ok(v) => v,
        Err(r) => return r,
    };
    let actor = match authorize(state, &headers, &owner_user, &repo_meta, write).await {
        Ok(a) => a,
        Err(r) => return r,
    };
    let content_type = headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    let resp = cgi(
        state,
        &owner_user.handle,
        &repo,
        svc,
        "POST",
        "",
        content_type.as_deref(),
        &headers,
        body,
        actor.as_ref().map(|u| u.handle.clone()),
    )
    .await;

    // After a successful push, kick off CI for the repo's default branch.
    if write && resp.status().is_success() {
        ci::on_push(state.clone(), owner_user.handle.clone(), repo_meta.clone());
    }
    resp
}

/// Bridge a request into the `git-http-backend` CGI.
#[allow(clippy::too_many_arguments)]
async fn cgi(
    state: &AppState,
    owner_handle: &str,
    repo_seg: &str,
    subpath: &str,
    method: &str,
    query: &str,
    content_type: Option<&str>,
    headers: &HeaderMap,
    body: Bytes,
    remote_user: Option<String>,
) -> Response {
    let cfg = state.cfg.clone();
    let project_root = cfg.repos_dir.join(owner_handle);
    let path_info = format!("/{repo_seg}/{subpath}");
    let git_protocol = headers
        .get("git-protocol")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    let backend = cfg.git_http_backend.clone();
    let method = method.to_string();
    let query = query.to_string();
    let content_type = content_type.map(|s| s.to_string());

    let out = tokio::task::spawn_blocking(move || {
        let mut cmd = Command::new(&backend);
        cmd.env("GIT_PROJECT_ROOT", &project_root)
            .env("GIT_HTTP_EXPORT_ALL", "1")
            .env("PATH_INFO", &path_info)
            .env("REQUEST_METHOD", &method)
            .env("QUERY_STRING", &query)
            .env("CONTENT_LENGTH", body.len().to_string())
            .env("REMOTE_ADDR", "127.0.0.1")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if let Some(ct) = &content_type {
            cmd.env("CONTENT_TYPE", ct);
        }
        if let Some(gp) = &git_protocol {
            cmd.env("GIT_PROTOCOL", gp);
        }
        if let Some(ru) = &remote_user {
            cmd.env("REMOTE_USER", ru);
        }
        let mut child = cmd.spawn()?;
        if let Some(mut stdin) = child.stdin.take() {
            stdin.write_all(&body)?;
        }
        let output = child.wait_with_output()?;
        Ok::<_, std::io::Error>(output)
    })
    .await;

    let output = match out {
        Ok(Ok(o)) => o,
        Ok(Err(e)) => {
            tracing::error!("git-http-backend io error: {e}");
            return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error\n").into_response();
        }
        Err(e) => {
            tracing::error!("git-http-backend join error: {e}");
            return (StatusCode::INTERNAL_SERVER_ERROR, "git backend panicked\n").into_response();
        }
    };
    if !output.status.success() && output.stdout.is_empty() {
        tracing::error!(
            "git-http-backend failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        return (StatusCode::INTERNAL_SERVER_ERROR, "git backend failed\n").into_response();
    }

    parse_cgi(output.stdout)
}

/// Split CGI output into headers + body and build an axum Response.
fn parse_cgi(raw: Vec<u8>) -> Response {
    let sep_crlf = find(&raw, b"\r\n\r\n");
    let sep_lf = find(&raw, b"\n\n");
    let (head_end, body_start) = match (sep_crlf, sep_lf) {
        (Some(a), Some(b)) if a <= b => (a, a + 4),
        (_, Some(b)) => (b, b + 2),
        (Some(a), None) => (a, a + 4),
        (None, None) => (0, 0),
    };
    let header_block = String::from_utf8_lossy(&raw[..head_end]).to_string();
    let body = raw[body_start..].to_vec();

    let mut status = StatusCode::OK;
    let mut builder = Response::builder();
    for line in header_block.lines() {
        if let Some((k, v)) = line.split_once(':') {
            let k = k.trim();
            let v = v.trim();
            if k.eq_ignore_ascii_case("Status") {
                if let Some(code) = v.split_whitespace().next() {
                    if let Ok(c) = code.parse::<u16>() {
                        status = StatusCode::from_u16(c).unwrap_or(StatusCode::OK);
                    }
                }
            } else {
                builder = builder.header(k, v);
            }
        }
    }
    builder
        .status(status)
        .body(axum::body::Body::from(body))
        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build error\n").into_response())
}

fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    haystack.windows(needle.len()).position(|w| w == needle)
}