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