//! The read-only Git mirror (hub spec ยง14): the default bookmark exported //! with oxy's own `git export` into `/mirrors//.git` //! after every update, and served over Git's smart HTTP by `git //! http-backend` behind the hub's access checks. Pushing to it is refused. use crate::auth::{self, Access}; use crate::db::{self, repos::RepoRow}; use crate::repos::RepoName; use crate::Shared; use axum::body::Bytes; use axum::extract::{Path, RawQuery, State}; use axum::http::{header, HeaderMap, Method, StatusCode}; use axum::response::{IntoResponse, Response}; use std::path::PathBuf; use tokio::io::AsyncWriteExt; pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf { app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name)) } /// Export the default bookmark into the mirror, if the mirror is on. pub async fn refresh(app: &Shared, row: &RepoRow) { if !row.git_mirror { return; } let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; let dir = mirror_dir(app, &repo); let bookmark = row.default_bookmark.clone(); let result = app .repos .write(&repo, move |r, _| { let mut r = oxy_core::repo::Repo::open(r.root())?; r.config.bookmark = bookmark.clone(); let report = r.git_export_as(&dir, &bookmark)?; // Point HEAD at the exported branch so clones check it out. std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?; let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default(); Ok((report.exported, state)) }) .await; match result { Ok((_, state)) => { db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok(); } Err(e) => tracing::warn!("git mirror of {}: {e}", row.slug()), } } /// Who a Git client is: HTTP Basic with a personal access token as the /// password (any user name), or anonymous. async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result, StatusCode> { let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) }; let token = if let Some(b) = v.strip_prefix("Basic ") { let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?; let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?; s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)? } else if let Some(t) = v.strip_prefix("Bearer ") { t.trim().to_string() } else { return Err(StatusCode::UNAUTHORIZED); }; let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match tok { Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => { db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } _ => Err(StatusCode::UNAUTHORIZED), } } fn base64_decode(s: &str) -> Option> { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = Vec::new(); let mut buf = 0u32; let mut bits = 0; for c in s.bytes().filter(|c| *c != b'=') { let v = T.iter().position(|x| *x == c)? as u32; buf = (buf << 6) | v; bits += 6; if bits >= 8 { bits -= 8; out.push((buf >> bits) as u8); buf &= (1 << bits) - 1; } } Some(out) } fn challenge() -> Response { (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"OxygenHub\"")], "authentication required").into_response() } /// Every smart-HTTP path under `/{owner}/{name}.git/`. pub async fn serve( State(app): State, Path((owner, name_git, rest)): Path<(String, String, String)>, method: Method, RawQuery(query): RawQuery, headers: HeaderMap, body: Bytes, ) -> Response { let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() }; let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() }; let query = query.unwrap_or_default(); if rest.contains("git-receive-pack") || query.contains("git-receive-pack") { return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response(); } let row = match db::repos::get(&app.db, &owner, name).await { Ok(Some(r)) if r.git_mirror => r, _ => return StatusCode::NOT_FOUND.into_response(), }; let account = match git_caller(&app, &headers).await { Ok(a) => a, Err(_) => return challenge(), }; let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten(); if !auth::roles::allows(role, Access::Read) { return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() }; } let dir = mirror_dir(&app, &repo); if !dir.join("HEAD").exists() { refresh(&app, &row).await; } let root = app.config.data.join("mirrors"); let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into()); let mut cmd = tokio::process::Command::new(git); cmd.arg("http-backend") .env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) .env("GIT_PROJECT_ROOT", &root) .env("GIT_HTTP_EXPORT_ALL", "1") .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}")) .env("REQUEST_METHOD", method.as_str()) .env("QUERY_STRING", &query) .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("")) .env("CONTENT_LENGTH", body.len().to_string()) .env("GIT_CONFIG_NOSYSTEM", "1") .env("HOME", &root) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) { cmd.env("HTTP_CONTENT_ENCODING", enc); } if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) { cmd.env("GIT_PROTOCOL", p); } let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(), }; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(&body).await; } let out = match child.wait_with_output().await { Ok(o) => o, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; cgi_response(&out.stdout) } /// Turn CGI output (headers, blank line, body) into a response. fn cgi_response(raw: &[u8]) -> Response { let split = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| (i, 4)).or_else(|| raw.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2))); let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() }; let head = String::from_utf8_lossy(&raw[..at]); let body = raw[at + sep..].to_vec(); let mut status = StatusCode::OK; let mut builder = Response::builder(); for line in head.lines() { let Some((k, v)) = line.split_once(':') else { continue }; let (k, v) = (k.trim(), v.trim()); if k.eq_ignore_ascii_case("status") { if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::().ok()) { status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK); } } else { builder = builder.header(k, v); } } builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()) } #[cfg(test)] mod tests { use super::*; #[test] fn base64_and_cgi() { assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret"); let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope"); assert_eq!(r.status(), StatusCode::NOT_FOUND); } }