use axum::body::{Body, to_bytes}; use axum::extract::{Path as AxumPath, RawQuery, State}; use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header}; use axum::response::{IntoResponse, Response}; use crate::githttp; use crate::render::encode_path; use super::{AppState, WebResult, internal, x_accel}; /// Read-only static access to an allowlisted subset of the bare repository, /// for the in-browser git client (and dumb-protocol clones). Objects are /// content-addressed and immutable; refs are never cached. pub(super) async fn git_dir( State(state): State, AxumPath((user, repo, path)): AxumPath<(String, String, String)>, ) -> WebResult { if !allowed_git_path(&path) { return Err((StatusCode::NOT_FOUND, "not served".into())); } let entry = state.resolve(&user, &repo)?; let repo_dir = entry .repository .path .file_name() .ok_or_else(|| internal("repository path has no file name"))? .to_string_lossy(); let mut response = x_accel( &state.config.git_internal_prefix, &format!( "{}/{}/{}", encode_path(&user), encode_path(&repo_dir), encode_path(&path) ), )?; let cache_control = if path.starts_with("objects/") && path != "objects/info/packs" { "public, max-age=31536000, immutable" } else { "no-cache" }; response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(cache_control), ); Ok(response) } /// `HEAD`, refs, and the object store; never `config` (may hold credentials /// for mirrors) or `hooks/`. fn allowed_git_path(path: &str) -> bool { let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|byte| byte.is_ascii_hexdigit()); match path { "HEAD" | "packed-refs" | "info/refs" | "objects/info/packs" => true, path if path.starts_with("refs/") => path .split('/') .all(|component| !component.is_empty() && component != "." && component != ".."), path => match path.strip_prefix("objects/") { Some(rest) => match rest.strip_prefix("pack/") { Some(pack) => { let stem = pack .strip_suffix(".pack") .or_else(|| pack.strip_suffix(".idx")) .and_then(|stem| stem.strip_prefix("pack-")); stem.is_some_and(is_hex) } None => matches!( rest.split_once('/'), Some((fan, name)) if fan.len() == 2 && is_hex(fan) && is_hex(name) ), }, None => false, }, } } pub(super) async fn info_refs( State(state): State, AxumPath((user, repo)): AxumPath<(String, String)>, RawQuery(query): RawQuery, headers: HeaderMap, ) -> WebResult { if query.as_deref() != Some("service=git-upload-pack") { return Err(( StatusCode::FORBIDDEN, "only git-upload-pack is supported".into(), )); } let entry = state.resolve(&user, &repo)?; githttp::advertise(&entry.repository.path, &headers, state.git_permit()?) } pub(super) async fn upload_pack( State(state): State, AxumPath((user, repo)): AxumPath<(String, String)>, request: Request, ) -> WebResult { let entry = state.resolve(&user, &repo)?; let permit = state.git_permit()?; let (parts, body) = request.into_parts(); let body = to_bytes(body, 64 << 20).await.map_err(|_| { ( StatusCode::PAYLOAD_TOO_LARGE, "git request body is too large".into(), ) })?; githttp::upload(&entry.repository.path, &parts.headers, body, permit) } pub(super) async fn raw_blob( State(state): State, AxumPath((user, repo, oid, path)): AxumPath<(String, String, String, String)>, ) -> WebResult { let oid = gix::ObjectId::from_hex(oid.as_bytes()) .map_err(|_| (StatusCode::NOT_FOUND, "blob not found".into()))?; let repo_path = state.resolve(&user, &repo)?.repository.path; let permit = state.git_permit()?; let data = tokio::task::spawn_blocking(move || -> anyhow::Result>> { let _permit = permit; let repo = gix::open(repo_path)?; let Ok(object) = repo.find_object(oid) else { return Ok(None); }; Ok((object.kind == gix::object::Kind::Blob).then(|| object.data.to_vec())) }) .await .map_err(internal)? .map_err(internal)? .ok_or_else(|| (StatusCode::NOT_FOUND, "blob not found".into()))?; let content_type = raw_content_type(&path, &data); let mut response = Body::from(data).into_response(); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_str(&content_type).map_err(internal)?, ); response.headers_mut().insert( header::CONTENT_DISPOSITION, HeaderValue::from_static("inline"), ); response.headers_mut().insert( "content-security-policy", HeaderValue::from_static( "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'", ), ); response.headers_mut().insert( "x-content-type-options", HeaderValue::from_static("nosniff"), ); response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), ); Ok(response) } fn raw_content_type(path: &str, data: &[u8]) -> String { if std::str::from_utf8(data).is_ok() { return "text/plain; charset=utf-8".into(); } mime_guess::from_path(path) .first() .filter(|mime| { let essence = mime.essence_str(); essence.starts_with("audio/") || essence.starts_with("video/") || essence.starts_with("image/") && essence != "image/svg+xml" }) .map(|mime| mime.to_string()) .unwrap_or_else(|| "application/octet-stream".into()) } #[cfg(test)] mod tests { use super::raw_content_type; #[test] fn raw_blobs_never_render_active_content() { assert_eq!( raw_content_type("page.html", b""), "text/plain; charset=utf-8" ); assert_eq!( raw_content_type("image.svg", b""), "text/plain; charset=utf-8" ); assert_eq!( raw_content_type("page.html", b"\xff\xfe"), "application/octet-stream" ); assert_eq!( raw_content_type("image.png", b"\x89PNG\r\n\x1a\n"), "image/png" ); } }