use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use std::path::Path;
use std::sync::Arc;
use tokio::net::TcpListener;
use crate::auth::check_auth;
use crate::git;
use crate::lfs;
use crate::Config;
pub async fn serve(listener: TcpListener, config: Arc<Config>) {
loop {
let (stream, _) = listener.accept().await.unwrap();
let config = config.clone();
tokio::spawn(async move {
let svc = service_fn(|req| {
let config = config.clone();
async move { handle_request(req, &config).await }
});
http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await
.ok();
});
}
}
pub async fn handle_request(
req: Request<Incoming>,
config: &Config,
) -> Result<Response<Full<Bytes>>, hyper::Error> {
if let Err(resp) = check_auth(&req, config) {
return Ok(resp);
}
match route(req, config).await {
Ok(r) => Ok(r),
Err(msg) => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Full::new(Bytes::from(msg)))
.unwrap()),
}
}
pub fn parse_path(path: &str) -> Option<(&str, &str)> {
let path = path.trim_start_matches('/');
for endpoint in ["info/refs", "git-upload-pack", "git-receive-pack"] {
if let Some(repo) = path.strip_suffix(&format!("/{}", endpoint)) {
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if !repo.is_empty() {
return Some((repo, endpoint));
}
}
}
None
}
fn parse_archive_path(path: &str) -> Option<(&str, &str, &str)> {
let path = path.trim_start_matches('/');
let (repo, rest) = if let Some(idx) = path.find(".git/archive/") {
(&path[..idx], &path[idx + ".git/archive/".len()..])
} else {
return None;
};
if repo.is_empty() || rest.is_empty() {
return None;
}
let (tree, format) = if let Some(base) = rest.strip_suffix(".tar.gz") {
(base, "tar.gz")
} else if let Some(base) = rest.strip_suffix(".zip") {
(base, "zip")
} else if let Some(base) = rest.strip_suffix(".tar") {
(base, "tar")
} else {
return None;
};
Some((repo, tree, format))
}
async fn route(
req: Request<Incoming>,
config: &Config,
) -> Result<Response<Full<Bytes>>, String> {
let path = req.uri().path().to_string();
let git_protocol: Option<String> = req
.headers()
.get("Git-Protocol")
.and_then(|v| v.to_str().ok())
.map(String::from);
let base_url = format!("http://{}:{}", config.host, config.port);
if path.contains("/info/lfs/") {
if let Some(result) = lfs::handle_lfs(req, &config.root, &base_url).await {
return result;
}
return Err("Invalid LFS request".into());
}
if path.contains("/archive/") {
if let Some((repo_name, tree, format)) = parse_archive_path(&path) {
if repo_name.contains("..") {
return Err("Invalid repo path".into());
}
let repo_path = config.root.join(repo_name);
if !git::is_git_repo(&repo_path) {
return Err(format!("Repository '{}' not found", repo_name));
}
let data = git::archive(&repo_path, tree, format).await?;
let content_type = match format {
"tar.gz" => "application/gzip",
"zip" => "application/zip",
"tar" => "application/x-tar",
_ => "application/octet-stream",
};
return Response::builder()
.status(StatusCode::OK)
.header("Content-Type", content_type)
.header(
"Content-Disposition",
format!("attachment; filename=\"{}-{}.{}\"", repo_name, tree, format),
)
.body(Full::new(Bytes::from(data)))
.map_err(|e| e.to_string());
}
}
let (repo_name, endpoint) =
parse_path(&path).ok_or("Usage: git clone http://host:port/<path>.git")?;
if repo_name.contains("..") {
return Err("Invalid repo path".into());
}
if repo_name.split('/').any(|seg| seg == ".git") || repo_name == ".git" {
return Err("Invalid repo path".into());
}
let repo_path = config.root.join(repo_name);
if !git::is_git_repo(&repo_path) {
if let Some(parent) = repo_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
git::init_repo(&repo_path, config).await?;
println!("+ Created repo: {}", repo_name);
}
match endpoint {
"info/refs" => {
let svc = req
.uri()
.query()
.and_then(|q| q.strip_prefix("service="))
.ok_or("Missing service param")?;
let (content_type, body) =
git::info_refs(&repo_path, svc, git_protocol.as_deref()).await?;
Response::builder()
.header("Content-Type", content_type)
.header("Cache-Control", "no-cache")
.body(Full::new(Bytes::from(body)))
.map_err(|e| e.to_string())
}
"git-upload-pack" | "git-receive-pack" => {
let body = req.collect().await.map_err(|e| e.to_string())?.to_bytes();
let (content_type, data) =
git::run_pack(&repo_path, endpoint, &body, git_protocol.as_deref()).await?;
let activity_kind = if endpoint == "git-receive-pack" {
"push"
} else {
"pull"
};
let activity_path = repo_path.clone();
tokio::spawn(async move {
git::record_activity(&activity_path, activity_kind).await;
});
Response::builder()
.header("Content-Type", content_type)
.header("Cache-Control", "no-cache")
.body(Full::new(Bytes::from(data)))
.map_err(|e| e.to_string())
}
_ => Err(format!("Unknown endpoint: {}", endpoint)),
}
}
pub async fn list_repos(root: &Path, host: &str, port: u16, recursive: bool) {
let mut repos = Vec::new();
find_repos_async(root, root, recursive, &mut repos).await;
repos.sort();
if repos.is_empty() {
println!("No repos yet. Push to create one:");
println!(" git remote add origin http://{}:{}/name.git", host, port);
println!(" git push -u origin main");
} else {
println!("Repos:");
for r in &repos {
println!(" http://{}:{}/{}.git", host, port, r);
}
}
println!();
}
async fn find_repos_async(
base: &Path,
dir: &Path,
recursive: bool,
out: &mut Vec<String>,
) {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path
.file_name()
.map_or(false, |n| n.to_string_lossy().starts_with('.'))
{
continue;
}
if !path.is_dir() {
continue;
}
if git::is_git_repo(&path) {
if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_string_lossy().into_owned());
}
} else if recursive {
Box::pin(find_repos_async(base, &path, true, out)).await;
}
}
}