use std::{sync::Arc, time::Instant}; use actix_web::{App, HttpResponse, HttpServer, dev::Service, web}; use cache::AppCache; use config::AppConfig; use db::database::AppDatabase; use sqlx; use tracing_actix_web::TracingLogger; use track::{CounterVec, HistogramVec}; pub mod action; pub mod auth; pub mod handler; pub mod lfs; pub mod lfs_routes; pub mod rate_limit; pub mod routes; pub mod utils; const REQUEST_LOG_EXCLUDED_PATHS: &[&str] = &[ "/health", "/live", "/ready", "/metrics", "/favicon.ico", "/robots.txt", ]; fn should_log_request(path: &str) -> bool { !REQUEST_LOG_EXCLUDED_PATHS.contains(&path) } fn record_http_request( registry: &track::MetricsRegistry, method: &str, status: u16, elapsed: std::time::Duration, ) { http_requests_total(registry) .with_label_values(&[method, &status.to_string()]) .inc(); http_request_duration(registry) .with_label_values(&[method, &status.to_string()]) .observe(elapsed.as_secs_f64()); } fn http_requests_total(registry: &track::MetricsRegistry) -> CounterVec { registry .register_counter_vec( "http_requests_total", "Total HTTP requests", &["method", "status"], ) .expect("failed to register http_requests_total") } fn http_request_duration(registry: &track::MetricsRegistry) -> HistogramVec { registry .register_histogram_vec( "http_request_duration_seconds", "HTTP request duration in seconds", &["method", "status"], vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0], ) .expect("failed to register http_request_duration_seconds") } #[derive(Clone)] pub struct HttpAppState { pub db: AppDatabase, pub cache: AppCache, pub sync: crate::sync::ReceiveSyncService, pub rate_limiter: Arc, pub config: AppConfig, pub git_state: crate::AppGitState, pub metrics: Option, } async fn robots(state: web::Data) -> HttpResponse { let sitemap_url = state .config .git_http_domain() .map(|d| format!("{}/sitemap.xml", d.trim_end_matches('/'))) .unwrap_or_default(); let body = if sitemap_url.is_empty() { "User-agent: *\nDisallow: /\n".to_string() } else { format!("User-agent: *\nDisallow: /\n\nSitemap: {sitemap_url}\n") }; HttpResponse::Ok() .content_type("text/plain; charset=utf-8") .body(body) } async fn health(state: web::Data) -> HttpResponse { let db_ok = sqlx::query("SELECT 1") .execute(state.db.reader()) .await .is_ok(); let cache_ok = state.cache.ping_cluster().await.is_ok(); if db_ok && cache_ok { HttpResponse::Ok().json(serde_json::json!({ "status": "ok", "db": "ok", "cache": "ok", })) } else { HttpResponse::ServiceUnavailable().json(serde_json::json!({ "status": "unhealthy", "db": if db_ok { "ok" } else { "error" }, "cache": if cache_ok { "ok" } else { "error" }, })) } } async fn metrics(state: web::Data) -> HttpResponse { let Some(metrics) = &state.metrics else { return HttpResponse::NotFound() .content_type("text/plain") .body("metrics not configured"); }; match metrics.encode() { Ok(body) => HttpResponse::Ok() .content_type("text/plain; version=0.0.4") .body(body), Err(e) => HttpResponse::InternalServerError() .content_type("text/plain") .body(format!("metrics encoding error: {e}")), } } pub fn git_http_cfg(cfg: &mut web::ServiceConfig) { cfg.route("/robots.txt", web::get().to(robots)) .route("/health", web::get().to(health)) .route("/metrics", web::get().to(metrics)) .route( "/{namespace}/{repo_name}.git/action", web::get().to(action::action_poll), ) .route( "/{namespace}/{repo_name}.git/info/refs", web::get().to(routes::info_refs), ) .route( "/{namespace}/{repo_name}.git/git-upload-pack", web::post().to(routes::upload_pack), ) .route( "/{namespace}/{repo_name}.git/git-receive-pack", web::post().to(routes::receive_pack), ) .route( "/{namespace}/{repo_name}.git/info/lfs/objects/batch", web::post().to(lfs_routes::lfs_batch), ) .route( "/{namespace}/{repo_name}.git/info/lfs/objects/{oid}", web::put().to(lfs_routes::lfs_upload), ) .route( "/{namespace}/{repo_name}.git/info/lfs/objects/{oid}", web::get().to(lfs_routes::lfs_download), ) .route( "/{namespace}/{repo_name}.git/info/lfs/locks", web::post().to(lfs_routes::lfs_lock_create), ) .route( "/{namespace}/{repo_name}.git/info/lfs/locks", web::get().to(lfs_routes::lfs_lock_list), ) .route( "/{namespace}/{repo_name}.git/info/lfs/locks/{id}", web::get().to(lfs_routes::lfs_lock_get), ) .route( "/{namespace}/{repo_name}.git/info/lfs/locks/{id}", web::delete().to(lfs_routes::lfs_lock_delete), ) .route( "/{namespace}/{repo_name}.git/graphql", web::post().to(crate::graphql::graphql_handle), ); } #[tracing::instrument(skip(config, db, cache, redis_pool, metrics_registry))] pub async fn run_http( config: AppConfig, db: AppDatabase, cache: AppCache, redis_pool: deadpool_redis::cluster::Pool, metrics_registry: Option, ) -> anyhow::Result<()> { let sync = crate::sync::ReceiveSyncService::new(redis_pool); let rate_limiter = Arc::new(rate_limit::RateLimiter::new( rate_limit::RateLimitConfig::default(), )); let _cleanup = rate_limiter.clone().start_cleanup(); let git_state = crate::AppGitState { cache: cache.clone(), db: db.clone(), }; let state = HttpAppState { db: db.clone(), cache: cache.clone(), sync, rate_limiter, config: config.clone(), git_state, metrics: metrics_registry, }; let http_port = config.git_http_port()?; tracing::info!("Starting git HTTP server on 0.0.0.0:{}", http_port); let server = HttpServer::new(move || { let request_metrics = state.metrics.clone(); App::new() .app_data(web::Data::new(state.clone())) .wrap(TracingLogger::default()) .wrap_fn(move |req, srv| { let metrics = request_metrics.clone(); let should_log = should_log_request(req.path()); let method = req.method().clone(); let path = req.path().to_owned(); let peer_addr = req.connection_info().peer_addr().map(str::to_owned); let started_at = Instant::now(); let fut = srv.call(req); async move { match fut.await { Ok(res) => { let elapsed = started_at.elapsed(); let status = res.status().as_u16(); if let Some(metrics) = &metrics { record_http_request(metrics, method.as_str(), status, elapsed); } if should_log { tracing::info!( method = %method, path = %path, status = status, elapsed_ms = elapsed.as_millis(), peer_addr = peer_addr.as_deref().unwrap_or("-"), "http request" ); } Ok(res) } Err(err) => { let elapsed = started_at.elapsed(); if let Some(metrics) = &metrics { record_http_request(metrics, method.as_str(), 500, elapsed); } if should_log { tracing::warn!( method = %method, path = %path, elapsed_ms = elapsed.as_millis(), peer_addr = peer_addr.as_deref().unwrap_or("-"), error = %err, "http request failed" ); } Err(err) } } } }) .configure(git_http_cfg) }) .bind(format!("0.0.0.0:{}", http_port))? .run(); let result = server.await; if let Err(e) = result { tracing::error!("HTTP server error: {}", e); } tracing::info!("Git HTTP server stopped"); Ok(()) }