use std::path::PathBuf; use std::sync::Arc; use axum::body::Body; use axum::extract::State; use axum::http::{Request, StatusCode}; use axum::response::Response; use axum::routing::{get, post}; use axum::{Json, Router}; use http_body_util::BodyExt; use serde::{Deserialize, Serialize}; use crate::atproto::{AtProtoClient, AtProtoRecord, InMemoryAtProto}; use crate::cache::RepoCache; use crate::flow::sync_repo_from_head; use crate::git_backend::{GitBackend, GitRequest}; use crate::iroh_node::IrohNode; use crate::{Error, Result}; #[derive(Clone)] struct AppState { node: Arc, cache_dir: PathBuf, } #[derive(Debug, Deserialize)] struct SyncRequest { repo_id: String, record_path: PathBuf, cache_dir: Option, } #[derive(Debug, Serialize)] struct SyncResponse { repo_id: String, head_peer: String, manifest_peer: String, packs_fetched: usize, packs_skipped: usize, } pub async fn serve(addr: &str, cache_dir: PathBuf) -> Result<()> { let node = Arc::new(IrohNode::new().await?); let app = app(node, cache_dir); let listener = tokio::net::TcpListener::bind(addr) .await .map_err(|err| Error::Invalid(format!("bind {addr}: {err}")))?; axum::serve(listener, app) .await .map_err(|err| Error::Invalid(format!("server error: {err}")))?; Ok(()) } pub fn app(node: Arc, cache_dir: PathBuf) -> Router { let state = AppState { node, cache_dir }; Router::new() .route("/health", get(health_handler)) .route("/status", get(status_handler)) .route("/git/*path", get(git_handler).post(git_handler)) .route("/sync", post(sync_handler)) .with_state(state) } async fn health_handler() -> StatusCode { StatusCode::OK } #[derive(Debug, Serialize)] struct StatusResponse { node_id: String, cache_dir: String, } async fn status_handler( State(state): State, ) -> std::result::Result, (StatusCode, String)> { Ok(Json(StatusResponse { node_id: state.node.node_id().to_string(), cache_dir: state.cache_dir.to_string_lossy().to_string(), })) } async fn sync_handler( State(state): State, Json(payload): Json, ) -> std::result::Result, (StatusCode, String)> { let record_bytes = tokio::fs::read(&payload.record_path) .await .map_err(|err| map_err(err.into()))?; let record: AtProtoRecord = serde_json::from_slice(&record_bytes).map_err(|err| map_err(err.into()))?; if record.repo_id != payload.repo_id { return Err(( StatusCode::BAD_REQUEST, format!( "record repo_id mismatch: expected {}, found {}", payload.repo_id, record.repo_id ), )); } let atproto = InMemoryAtProto::default(); atproto.put_record(&record).map_err(map_err)?; let cache_root = payload.cache_dir.unwrap_or_else(|| state.cache_dir.clone()); let cache = RepoCache::new(cache_root); let summary = sync_repo_from_head(&atproto, &state.node, &cache, &payload.repo_id) .await .map_err(map_err)?; Ok(Json(SyncResponse { repo_id: payload.repo_id, head_peer: summary.head_peer, manifest_peer: summary.manifest_peer, packs_fetched: summary.packs.fetched, packs_skipped: summary.packs.skipped, })) } async fn git_handler( State(state): State, req: Request, ) -> std::result::Result { let (parts, body) = req.into_parts(); let path_info = strip_git_prefix(parts.uri.path())?; let query_string = parts.uri.query().unwrap_or("").to_string(); let content_type = parts .headers .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(|value| value.to_string()); let body_bytes = body .collect() .await .map_err(|err| map_err(Error::Invalid(format!("read body: {err}"))))? .to_bytes() .to_vec(); let mut env = std::collections::HashMap::new(); if let Some(user_agent) = parts.headers.get(axum::http::header::USER_AGENT) { if let Ok(value) = user_agent.to_str() { env.insert("HTTP_USER_AGENT".to_string(), value.to_string()); } } let git = GitBackend::new(); let response = git .handle_http_backend(GitRequest { method: parts.method.to_string(), path_info, query_string, content_type, body: body_bytes, project_root: state.cache_dir.clone(), env, }) .map_err(map_err)?; let mut builder = Response::builder().status(response.status); for (key, value) in response.headers { if let (Ok(name), Ok(value)) = ( key.parse::(), value.parse::(), ) { builder = builder.header(name, value); } } Ok(builder.body(Body::from(response.body)).unwrap()) } fn strip_git_prefix(path: &str) -> std::result::Result { if let Some(stripped) = path.strip_prefix("/git") { if stripped.is_empty() || stripped == "/" { return Err((StatusCode::BAD_REQUEST, "missing git path".to_string())); } return Ok(stripped.to_string()); } Err((StatusCode::BAD_REQUEST, "invalid git path".to_string())) } #[cfg(test)] mod tests { use super::strip_git_prefix; use axum::http::StatusCode; #[test] fn strip_git_prefix_accepts_repo_path() { let path = "/git/repo.git/info/refs"; let result = strip_git_prefix(path).unwrap(); assert_eq!(result, "/repo.git/info/refs"); } #[test] fn strip_git_prefix_rejects_root() { let err = strip_git_prefix("/git").unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("missing git path")); } #[test] fn strip_git_prefix_rejects_trailing_slash() { let err = strip_git_prefix("/git/").unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("missing git path")); } #[test] fn strip_git_prefix_rejects_invalid_prefix() { let err = strip_git_prefix("/foo/repo.git").unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("invalid git path")); } } fn map_err(err: Error) -> (StatusCode, String) { match err { Error::Invalid(msg) => (StatusCode::BAD_REQUEST, msg), Error::NotFound(msg) => (StatusCode::NOT_FOUND, msg), other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), } }