| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | use axum::body::Body; |
| 14 | use axum::extract::{Path, State}; |
| 15 | use axum::http::{HeaderMap, StatusCode, header}; |
| 16 | use axum::response::{IntoResponse, Response}; |
| 17 | use axum_extra::extract::cookie::CookieJar; |
| 18 | use base64::Engine as _; |
| 19 | use model::{Repo, User}; |
| 20 | use tokio::io::AsyncWriteExt as _; |
| 21 | use tokio_util::io::ReaderStream; |
| 22 | |
| 23 | use crate::AppState; |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | #[must_use] |
| 29 | pub fn split_git(rest: &str) -> Option<(&str, &str)> { |
| 30 | let idx = rest.find(".git/")?; |
| 31 | Some((&rest[..idx], &rest[idx + ".git/".len()..])) |
| 32 | } |
| 33 | |
| 34 | |
| 35 | pub async fn http_get( |
| 36 | state: &AppState, |
| 37 | jar: &CookieJar, |
| 38 | headers: &HeaderMap, |
| 39 | owner: &str, |
| 40 | repo_path: &str, |
| 41 | tail: &str, |
| 42 | service: Option<&str>, |
| 43 | ) -> Response { |
| 44 | if tail != "info/refs" { |
| 45 | return StatusCode::NOT_FOUND.into_response(); |
| 46 | } |
| 47 | match service { |
| 48 | Some("git-upload-pack") => {} |
| 49 | Some("git-receive-pack") => return push_disabled(state, owner, repo_path), |
| 50 | _ => return StatusCode::FORBIDDEN.into_response(), |
| 51 | } |
| 52 | |
| 53 | let repo = match authorize(state, jar, headers, owner, repo_path, auth::Access::Read).await { |
| 54 | Ok(repo) => repo, |
| 55 | Err(response) => return response, |
| 56 | }; |
| 57 | info_refs(state, &repo, headers).await |
| 58 | } |
| 59 | |
| 60 | |
| 61 | |
| 62 | pub async fn pack_rpc( |
| 63 | State(state): State<AppState>, |
| 64 | jar: CookieJar, |
| 65 | headers: HeaderMap, |
| 66 | Path((owner, rest)): Path<(String, String)>, |
| 67 | body: Body, |
| 68 | ) -> Response { |
| 69 | |
| 70 | if let Some((repo_path, tail)) = crate::lfs::split_lfs(&rest) { |
| 71 | let repo_path = repo_path.to_string(); |
| 72 | return match tail { |
| 73 | "objects/batch" => crate::lfs::batch(state, jar, headers, owner, repo_path, body).await, |
| 74 | t if t.starts_with("objects/") && t.ends_with("/verify") => { |
| 75 | crate::lfs::verify(state, jar, headers, owner, repo_path, body).await |
| 76 | } |
| 77 | _ => StatusCode::NOT_FOUND.into_response(), |
| 78 | }; |
| 79 | } |
| 80 | let Some((repo_path, tail)) = split_git(&rest) else { |
| 81 | return StatusCode::NOT_FOUND.into_response(); |
| 82 | }; |
| 83 | match tail { |
| 84 | "git-upload-pack" => {} |
| 85 | "git-receive-pack" => return push_disabled(&state, &owner, repo_path), |
| 86 | _ => return StatusCode::NOT_FOUND.into_response(), |
| 87 | } |
| 88 | |
| 89 | let repo = match authorize( |
| 90 | &state, |
| 91 | &jar, |
| 92 | &headers, |
| 93 | &owner, |
| 94 | repo_path, |
| 95 | auth::Access::Read, |
| 96 | ) |
| 97 | .await |
| 98 | { |
| 99 | Ok(repo) => repo, |
| 100 | Err(response) => return response, |
| 101 | }; |
| 102 | upload_pack_rpc(&state, &repo, &headers, body).await |
| 103 | } |
| 104 | |
| 105 | |
| 106 | |
| 107 | async fn info_refs(state: &AppState, repo: &Repo, headers: &HeaderMap) -> Response { |
| 108 | let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) { |
| 109 | Ok(p) => p, |
| 110 | Err(e) => return internal(&e.to_string()), |
| 111 | }; |
| 112 | let env = pack_env(state, headers, repo); |
| 113 | let child = git::pack::spawn( |
| 114 | &state.config.git.binary, |
| 115 | git::pack::Service::UploadPack, |
| 116 | &["--stateless-rpc", "--advertise-refs"], |
| 117 | &repo_dir, |
| 118 | &env, |
| 119 | ); |
| 120 | let output = match child { |
| 121 | Ok(child) => match child.wait_with_output().await { |
| 122 | Ok(out) => out, |
| 123 | Err(e) => return internal(&e.to_string()), |
| 124 | }, |
| 125 | Err(e) => return internal(&e.to_string()), |
| 126 | }; |
| 127 | |
| 128 | let mut framed = git::pack::pkt_line("# service=git-upload-pack\n").into_bytes(); |
| 129 | framed.extend_from_slice(git::pack::FLUSH_PKT.as_bytes()); |
| 130 | framed.extend_from_slice(&output.stdout); |
| 131 | |
| 132 | ( |
| 133 | [ |
| 134 | ( |
| 135 | header::CONTENT_TYPE, |
| 136 | "application/x-git-upload-pack-advertisement", |
| 137 | ), |
| 138 | (header::CACHE_CONTROL, "no-cache"), |
| 139 | ], |
| 140 | framed, |
| 141 | ) |
| 142 | .into_response() |
| 143 | } |
| 144 | |
| 145 | |
| 146 | |
| 147 | async fn upload_pack_rpc( |
| 148 | state: &AppState, |
| 149 | repo: &Repo, |
| 150 | headers: &HeaderMap, |
| 151 | body: Body, |
| 152 | ) -> Response { |
| 153 | let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) { |
| 154 | Ok(p) => p, |
| 155 | Err(e) => return internal(&e.to_string()), |
| 156 | }; |
| 157 | |
| 158 | |
| 159 | |
| 160 | |
| 161 | let Ok(raw) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { |
| 162 | return (StatusCode::BAD_REQUEST, "request body too large").into_response(); |
| 163 | }; |
| 164 | let request = if header_eq(headers, header::CONTENT_ENCODING, "gzip") { |
| 165 | match gunzip(&raw) { |
| 166 | Ok(data) => data, |
| 167 | Err(_) => return (StatusCode::BAD_REQUEST, "invalid gzip body").into_response(), |
| 168 | } |
| 169 | } else { |
| 170 | raw.to_vec() |
| 171 | }; |
| 172 | |
| 173 | let env = pack_env(state, headers, repo); |
| 174 | let mut child = match git::pack::spawn( |
| 175 | &state.config.git.binary, |
| 176 | git::pack::Service::UploadPack, |
| 177 | &["--stateless-rpc"], |
| 178 | &repo_dir, |
| 179 | &env, |
| 180 | ) { |
| 181 | Ok(child) => child, |
| 182 | Err(e) => return internal(&e.to_string()), |
| 183 | }; |
| 184 | |
| 185 | let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else { |
| 186 | return internal("failed to capture pack subprocess stdio"); |
| 187 | }; |
| 188 | |
| 189 | |
| 190 | |
| 191 | tokio::spawn(async move { |
| 192 | let _ = stdin.write_all(&request).await; |
| 193 | let _ = stdin.shutdown().await; |
| 194 | drop(stdin); |
| 195 | let _ = child.wait().await; |
| 196 | }); |
| 197 | |
| 198 | let stream = ReaderStream::new(stdout); |
| 199 | ( |
| 200 | [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")], |
| 201 | Body::from_stream(stream), |
| 202 | ) |
| 203 | .into_response() |
| 204 | } |
| 205 | |
| 206 | |
| 207 | |
| 208 | |
| 209 | #[allow(clippy::result_large_err)] |
| 210 | pub(crate) async fn authorize( |
| 211 | state: &AppState, |
| 212 | jar: &CookieJar, |
| 213 | headers: &HeaderMap, |
| 214 | owner: &str, |
| 215 | repo_path: &str, |
| 216 | required: auth::Access, |
| 217 | ) -> Result<Repo, Response> { |
| 218 | let owner_user = state.store.user_by_username(owner).await.ok().flatten(); |
| 219 | let repo = match owner_user { |
| 220 | Some(ref u) => state |
| 221 | .store |
| 222 | .repo_by_owner_path(&u.id, repo_path) |
| 223 | .await |
| 224 | .ok() |
| 225 | .flatten(), |
| 226 | None => None, |
| 227 | }; |
| 228 | let Some(repo) = repo else { |
| 229 | return Err(StatusCode::NOT_FOUND.into_response()); |
| 230 | }; |
| 231 | |
| 232 | let viewer = authenticate(state, jar, headers).await; |
| 233 | let viewer_id = viewer.as_ref().map(|u| auth::Viewer { |
| 234 | id: u.id.clone(), |
| 235 | is_admin: u.is_admin, |
| 236 | }); |
| 237 | let collaborator = match &viewer { |
| 238 | Some(u) => state |
| 239 | .store |
| 240 | .effective_permission(&repo.id, &u.id) |
| 241 | .await |
| 242 | .ok() |
| 243 | .flatten() |
| 244 | .and_then(|p| auth::Permission::parse(&p)), |
| 245 | None => None, |
| 246 | }; |
| 247 | let access = auth::access( |
| 248 | viewer_id.as_ref(), |
| 249 | &repo, |
| 250 | collaborator, |
| 251 | state.allow_anonymous(), |
| 252 | ); |
| 253 | if access >= required { |
| 254 | Ok(repo) |
| 255 | } else if viewer.is_none() { |
| 256 | Err(unauthorized()) |
| 257 | } else { |
| 258 | |
| 259 | Err(StatusCode::NOT_FOUND.into_response()) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | |
| 264 | |
| 265 | |
| 266 | pub(crate) async fn authenticate( |
| 267 | state: &AppState, |
| 268 | jar: &CookieJar, |
| 269 | headers: &HeaderMap, |
| 270 | ) -> Option<User> { |
| 271 | if let Some(cookie) = jar.get(&state.config.auth.cookie_name) |
| 272 | && let Ok(Some(user)) = state |
| 273 | .store |
| 274 | .user_for_session(&auth::session_id(cookie.value())) |
| 275 | .await |
| 276 | { |
| 277 | return Some(user); |
| 278 | } |
| 279 | if let Some(user) = bearer_user(state, headers).await { |
| 280 | return Some(user); |
| 281 | } |
| 282 | let (username, password) = parse_basic(headers)?; |
| 283 | let user = state |
| 284 | .store |
| 285 | .user_by_username(&username) |
| 286 | .await |
| 287 | .ok() |
| 288 | .flatten()?; |
| 289 | if user.disabled_at.is_none() |
| 290 | && user.password_hash.is_some() |
| 291 | && auth::verify_password(&password, user.password_hash.as_deref()) |
| 292 | { |
| 293 | Some(user) |
| 294 | } else { |
| 295 | None |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | |
| 300 | |
| 301 | |
| 302 | async fn bearer_user(state: &AppState, headers: &HeaderMap) -> Option<User> { |
| 303 | let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; |
| 304 | let token = value.strip_prefix("Bearer ")?.trim(); |
| 305 | let now = crate::now_ms() / 1000; |
| 306 | let claims = auth::verify_jwt(state.secret.as_str(), token, now).ok()?; |
| 307 | state |
| 308 | .store |
| 309 | .user_by_id(&claims.sub) |
| 310 | .await |
| 311 | .ok() |
| 312 | .flatten() |
| 313 | .filter(|u| u.disabled_at.is_none()) |
| 314 | } |
| 315 | |
| 316 | |
| 317 | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { |
| 318 | let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; |
| 319 | let encoded = value.strip_prefix("Basic ")?; |
| 320 | let decoded = base64::engine::general_purpose::STANDARD |
| 321 | .decode(encoded.trim()) |
| 322 | .ok()?; |
| 323 | let text = String::from_utf8(decoded).ok()?; |
| 324 | let (user, pass) = text.split_once(':')?; |
| 325 | Some((user.to_string(), pass.to_string())) |
| 326 | } |
| 327 | |
| 328 | |
| 329 | fn pack_env(state: &AppState, headers: &HeaderMap, repo: &Repo) -> git::pack::PackEnv { |
| 330 | git::pack::PackEnv { |
| 331 | home: state.config.storage.data_dir.join("tmp"), |
| 332 | protocol: headers |
| 333 | .get("git-protocol") |
| 334 | .and_then(|v| v.to_str().ok()) |
| 335 | .map(str::to_string), |
| 336 | repo_id: Some(repo.id.clone()), |
| 337 | ..git::pack::PackEnv::default() |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | |
| 342 | fn push_disabled(state: &AppState, owner: &str, repo_path: &str) -> Response { |
| 343 | let host = &state.config.ssh.clone_host; |
| 344 | let port = state.config.ssh.clone_port; |
| 345 | let ssh_url = if port == 22 { |
| 346 | format!("git@{host}:{owner}/{repo_path}.git") |
| 347 | } else { |
| 348 | format!("ssh://git@{host}:{port}/{owner}/{repo_path}.git") |
| 349 | }; |
| 350 | ( |
| 351 | StatusCode::FORBIDDEN, |
| 352 | format!("Pushing over HTTPS is disabled. Push over SSH instead:\n\n {ssh_url}\n"), |
| 353 | ) |
| 354 | .into_response() |
| 355 | } |
| 356 | |
| 357 | |
| 358 | fn unauthorized() -> Response { |
| 359 | ( |
| 360 | StatusCode::UNAUTHORIZED, |
| 361 | [(header::WWW_AUTHENTICATE, "Basic realm=\"fabrica\"")], |
| 362 | "Authentication required.\n", |
| 363 | ) |
| 364 | .into_response() |
| 365 | } |
| 366 | |
| 367 | |
| 368 | fn internal(detail: &str) -> Response { |
| 369 | tracing::error!(error.detail = %detail, "pack transport error"); |
| 370 | (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response() |
| 371 | } |
| 372 | |
| 373 | |
| 374 | fn header_eq(headers: &HeaderMap, name: header::HeaderName, value: &str) -> bool { |
| 375 | headers |
| 376 | .get(name) |
| 377 | .and_then(|v| v.to_str().ok()) |
| 378 | .is_some_and(|v| v.eq_ignore_ascii_case(value)) |
| 379 | } |
| 380 | |
| 381 | |
| 382 | fn gunzip(data: &[u8]) -> std::io::Result<Vec<u8>> { |
| 383 | use std::io::Read as _; |
| 384 | let mut decoder = flate2::read::GzDecoder::new(data); |
| 385 | let mut out = Vec::new(); |
| 386 | decoder.read_to_end(&mut out)?; |
| 387 | Ok(out) |
| 388 | } |