use std::collections::HashMap; use axum::Router; use axum::body::Body; use axum::extract::{Path, Query, Request, State}; use axum::http::{HeaderMap, Method}; use axum::response::{IntoResponse, Response}; use crate::share::access::{BasicAuth, allow_repo_read}; use crate::smart::dispatch; use crate::smart::error::{SmartError, SmartResult}; use crate::smart::lfs; use crate::smart::object; use crate::smart::rpc::GitService; use crate::smart::service::SmartService; pub fn router(service: SmartService) -> Router { Router::new() .route( "/{space}/{repo}/{*path}", axum::routing::get(dispatch) .post(dispatch) .put(dispatch) .head(dispatch), ) .with_state(service) } async fn dispatch( State(service): State, Path((space, repo, path)): Path<(String, String, String)>, Query(query): Query>, headers: HeaderMap, auth: BasicAuth, req: Request, ) -> Response { let method = req.method().clone(); let body = req.into_body(); tracing::debug!( method = %method, space = %space, repo = %repo, path = %path, "smart http request received" ); // git 客户端(含 actions/checkout)既可能访问 //.git,也可能省略 // .git 后缀,两种形态都要落到同一个仓库。 let repo_name = repo.strip_suffix(".git").unwrap_or(&repo).to_string(); if repo_name.is_empty() { return SmartError::BadRequest("expected '.git' in URL".into()).into_response(); } let path_lower = path.to_lowercase(); let result: SmartResult = match (path_lower.as_str(), method.clone()) { ("info/refs", m) if m == Method::GET || m == Method::HEAD => { let service_kind = query.get("service").cloned(); match dispatch::info_refs( &service, &space, &repo_name, &headers, &auth, service_kind.as_deref(), ) .await { Ok(mut resp) => { if m == Method::HEAD { *resp.body_mut() = Body::empty(); } Ok(resp) } Err(e) => Err(e), } } ("git-upload-pack", Method::POST) => { dispatch::rpc( &service, &space, &repo_name, &headers, body, &auth, GitService::UploadPack, ) .await } ("git-receive-pack", Method::POST) => { dispatch::rpc( &service, &space, &repo_name, &headers, body, &auth, GitService::ReceivePack, ) .await } (p, _) if p.starts_with("info/lfs/") => { lfs::dispatch( &service, &space, &repo_name, &headers, p, method.clone(), body, &auth, ) .await } (_, Method::GET) => { let resp = match service.resolve(&space, &repo_name).await { Ok(ctx) => match allow_repo_read(&service.git, &auth, &ctx).await { Ok(_) => { if !is_served_path(&path) { SmartError::Forbidden("path not served".into()).into_response() } else { let bare = service.bare_path(&ctx); match object::serve(&bare, &path).await { Ok(resp) => resp, Err(e) => e.into_response(), } } } Err(e) => e.into_response(), }, Err(e) => e.into_response(), }; Ok(resp) } _ => Ok(SmartError::BadRequest("method not allowed".into()).into_response()), }; result.unwrap_or_else(|e| { tracing::warn!( method = %method, space = %space, repo = %repo_name, path = %path, error = %e, "smart http request failed" ); e.into_response() }) } /// Whitelist of paths the GET fallback may serve. The dumb HTTP protocol /// fetches `info/refs`, `HEAD`, `objects/info/packs`, `objects/pack/*` /// and `objects//`; everything else (notably `config`, /// `hooks/*`, `packed-refs`, `info/exclude`) is internal and must not be /// exposed anonymously. fn is_served_path(path: &str) -> bool { if path == "info/refs" || path == "HEAD" { return true; } if let Some(rest) = path.strip_prefix("objects/") { if rest.starts_with("pack/") { return true; } if rest == "info/packs" || rest.starts_with("info/") { return true; } // objects/<2-char dir>/<38 hex> = loose object fetch if rest.len() >= 3 && rest.as_bytes()[2] == b'/' && rest.is_ascii() { let prefix = &rest[..2]; let after = &rest[3..]; if prefix.chars().all(|c| c.is_ascii_hexdigit()) && !after.is_empty() { return true; } } return false; } false }