| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
use crate::auth::{self, Access}; |
| 7 |
use crate::db::{self, repos::RepoRow}; |
| 8 |
use crate::repos::RepoName; |
| 9 |
use crate::Shared; |
| 10 |
use axum::body::Bytes; |
| 11 |
use axum::extract::{Path, RawQuery, State}; |
| 12 |
use axum::http::{header, HeaderMap, Method, StatusCode}; |
| 13 |
use axum::response::{IntoResponse, Response}; |
| 14 |
use std::path::PathBuf; |
| 15 |
use tokio::io::AsyncWriteExt; |
| 16 |
|
| 17 |
pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf { |
| 18 |
app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name)) |
| 19 |
} |
| 20 |
|
| 21 |
|
| 22 |
pub async fn refresh(app: &Shared, row: &RepoRow) -> Result<(), String> { |
| 23 |
if !row.git_mirror { |
| 24 |
return Ok(()); |
| 25 |
} |
| 26 |
let Some(repo) = RepoName::new(&row.owner, &row.name) else { return Ok(()) }; |
| 27 |
let dir = mirror_dir(app, &repo); |
| 28 |
let bookmark = row.default_bookmark.clone(); |
| 29 |
let result = app |
| 30 |
.repos |
| 31 |
.write(&repo, move |r, _| { |
| 32 |
let mut r = oxy_core::repo::Repo::open(r.root())?; |
| 33 |
r.config.bookmark = bookmark.clone(); |
| 34 |
let report = r.git_export_as(&dir, &bookmark)?; |
| 35 |
|
| 36 |
std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?; |
| 37 |
let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default(); |
| 38 |
Ok((report.exported, state)) |
| 39 |
}) |
| 40 |
.await; |
| 41 |
match result { |
| 42 |
Ok((_, state)) => { |
| 43 |
db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok(); |
| 44 |
Ok(()) |
| 45 |
} |
| 46 |
Err(e) => { |
| 47 |
tracing::warn!("git mirror of {}: {e}", row.slug()); |
| 48 |
Err(format!("git mirror of {}: {e}", row.slug())) |
| 49 |
} |
| 50 |
} |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> { |
| 56 |
let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) }; |
| 57 |
let token = if let Some(b) = v.strip_prefix("Basic ") { |
| 58 |
let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?; |
| 59 |
let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?; |
| 60 |
s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)? |
| 61 |
} else if let Some(t) = v.strip_prefix("Bearer ") { |
| 62 |
t.trim().to_string() |
| 63 |
} else { |
| 64 |
return Err(StatusCode::UNAUTHORIZED); |
| 65 |
}; |
| 66 |
let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
| 67 |
match tok { |
| 68 |
Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => { |
| 69 |
db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) |
| 70 |
} |
| 71 |
_ => Err(StatusCode::UNAUTHORIZED), |
| 72 |
} |
| 73 |
} |
| 74 |
|
| 75 |
fn base64_decode(s: &str) -> Option<Vec<u8>> { |
| 76 |
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
| 77 |
let mut out = Vec::new(); |
| 78 |
let mut buf = 0u32; |
| 79 |
let mut bits = 0; |
| 80 |
for c in s.bytes().filter(|c| *c != b'=') { |
| 81 |
let v = T.iter().position(|x| *x == c)? as u32; |
| 82 |
buf = (buf << 6) | v; |
| 83 |
bits += 6; |
| 84 |
if bits >= 8 { |
| 85 |
bits -= 8; |
| 86 |
out.push((buf >> bits) as u8); |
| 87 |
buf &= (1 << bits) - 1; |
| 88 |
} |
| 89 |
} |
| 90 |
Some(out) |
| 91 |
} |
| 92 |
|
| 93 |
fn challenge() -> Response { |
| 94 |
(StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"OxygenHub\"")], "authentication required").into_response() |
| 95 |
} |
| 96 |
|
| 97 |
|
| 98 |
pub async fn serve( |
| 99 |
State(app): State<Shared>, |
| 100 |
Path((owner, name_git, rest)): Path<(String, String, String)>, |
| 101 |
method: Method, |
| 102 |
RawQuery(query): RawQuery, |
| 103 |
headers: HeaderMap, |
| 104 |
body: Bytes, |
| 105 |
) -> Response { |
| 106 |
let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() }; |
| 107 |
let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() }; |
| 108 |
let query = query.unwrap_or_default(); |
| 109 |
if rest.contains("git-receive-pack") || query.contains("git-receive-pack") { |
| 110 |
return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response(); |
| 111 |
} |
| 112 |
let row = match db::repos::get(&app.db, &owner, name).await { |
| 113 |
Ok(Some(r)) if r.git_mirror => r, |
| 114 |
_ => return StatusCode::NOT_FOUND.into_response(), |
| 115 |
}; |
| 116 |
let account = match git_caller(&app, &headers).await { |
| 117 |
Ok(a) => a, |
| 118 |
Err(_) => return challenge(), |
| 119 |
}; |
| 120 |
let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten(); |
| 121 |
if !auth::roles::allows(role, Access::Read) { |
| 122 |
return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() }; |
| 123 |
} |
| 124 |
let dir = mirror_dir(&app, &repo); |
| 125 |
if !dir.join("HEAD").exists() { |
| 126 |
refresh(&app, &row).await.ok(); |
| 127 |
} |
| 128 |
let root = app.config.data.join("mirrors"); |
| 129 |
let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into()); |
| 130 |
let mut cmd = tokio::process::Command::new(git); |
| 131 |
cmd.arg("http-backend") |
| 132 |
.env_clear() |
| 133 |
.env("PATH", std::env::var("PATH").unwrap_or_default()) |
| 134 |
.env("GIT_PROJECT_ROOT", &root) |
| 135 |
.env("GIT_HTTP_EXPORT_ALL", "1") |
| 136 |
.env("PATH_INFO", format!("/{owner}/{name_git}/{rest}")) |
| 137 |
.env("REQUEST_METHOD", method.as_str()) |
| 138 |
.env("QUERY_STRING", &query) |
| 139 |
.env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("")) |
| 140 |
.env("CONTENT_LENGTH", body.len().to_string()) |
| 141 |
.env("GIT_CONFIG_NOSYSTEM", "1") |
| 142 |
.env("HOME", &root) |
| 143 |
.stdin(std::process::Stdio::piped()) |
| 144 |
.stdout(std::process::Stdio::piped()) |
| 145 |
.stderr(std::process::Stdio::piped()); |
| 146 |
if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) { |
| 147 |
cmd.env("HTTP_CONTENT_ENCODING", enc); |
| 148 |
} |
| 149 |
if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) { |
| 150 |
cmd.env("GIT_PROTOCOL", p); |
| 151 |
} |
| 152 |
let mut child = match cmd.spawn() { |
| 153 |
Ok(c) => c, |
| 154 |
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(), |
| 155 |
}; |
| 156 |
if let Some(mut stdin) = child.stdin.take() { |
| 157 |
let _ = stdin.write_all(&body).await; |
| 158 |
} |
| 159 |
let out = match child.wait_with_output().await { |
| 160 |
Ok(o) => o, |
| 161 |
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), |
| 162 |
}; |
| 163 |
cgi_response(&out.stdout) |
| 164 |
} |
| 165 |
|
| 166 |
|
| 167 |
fn cgi_response(raw: &[u8]) -> Response { |
| 168 |
let split = |
| 169 |
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))); |
| 170 |
let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() }; |
| 171 |
let head = String::from_utf8_lossy(&raw[..at]); |
| 172 |
let body = raw[at + sep..].to_vec(); |
| 173 |
let mut status = StatusCode::OK; |
| 174 |
let mut builder = Response::builder(); |
| 175 |
for line in head.lines() { |
| 176 |
let Some((k, v)) = line.split_once(':') else { continue }; |
| 177 |
let (k, v) = (k.trim(), v.trim()); |
| 178 |
if k.eq_ignore_ascii_case("status") { |
| 179 |
if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) { |
| 180 |
status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK); |
| 181 |
} |
| 182 |
} else { |
| 183 |
builder = builder.header(k, v); |
| 184 |
} |
| 185 |
} |
| 186 |
builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()) |
| 187 |
} |
| 188 |
|
| 189 |
#[cfg(test)] |
| 190 |
mod tests { |
| 191 |
use super::*; |
| 192 |
|
| 193 |
#[test] |
| 194 |
fn base64_and_cgi() { |
| 195 |
assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret"); |
| 196 |
let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope"); |
| 197 |
assert_eq!(r.status(), StatusCode::NOT_FOUND); |
| 198 |
} |
| 199 |
} |