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)
}