/ src / lib.rs
lib.rs
  1  #![allow(clippy::type_complexity)]
  2  #![allow(clippy::too_many_arguments)]
  3  #![recursion_limit = "256"]
  4  pub mod error;
  5  
  6  use std::collections::HashMap;
  7  use std::num::NonZeroUsize;
  8  use std::process::Command;
  9  use std::str;
 10  use std::sync::Arc;
 11  use std::time::Duration;
 12  
 13  #[cfg(unix)]
 14  use tokio::signal::unix::{signal, SignalKind};
 15  
 16  use anyhow::Context as _;
 17  use axum::body::Body;
 18  use axum::http::Request;
 19  use axum::response::IntoResponse;
 20  use axum::routing::get;
 21  use axum::{middleware, Json, Router};
 22  use axum_listener::{DualAddr, DualListener};
 23  use hyper::body::Body as _;
 24  use hyper::header::CONTENT_TYPE;
 25  use hyper::Method;
 26  use tower_http::cors;
 27  use tower_http::cors::CorsLayer;
 28  use tower_http::trace::TraceLayer;
 29  use tracing::Span;
 30  
 31  use radicle::identity::RepoId;
 32  use radicle::Profile;
 33  
 34  use crate::api::RADICLE_VERSION;
 35  use crate::tracing_extra::{tracing_middleware, ColoredStatus, Paint, RequestId, TracingInfo};
 36  
 37  mod api;
 38  mod axum_extra;
 39  mod cache;
 40  mod git;
 41  mod raw;
 42  #[cfg(test)]
 43  mod test;
 44  mod tracing_extra;
 45  
 46  /// Default cache HTTP size.
 47  pub const DEFAULT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(100).unwrap();
 48  
 49  #[derive(Debug, Clone)]
 50  pub struct Options {
 51      pub aliases: HashMap<String, RepoId>,
 52      pub listen: DualAddr,
 53      pub cache: Option<NonZeroUsize>,
 54  }
 55  
 56  /// Run the Server.
 57  pub async fn run(options: Options) -> anyhow::Result<()> {
 58      let git_version = Command::new("git")
 59          .arg("version")
 60          .output()
 61          .context("'git' command must be available")?
 62          .stdout;
 63  
 64      tracing::info!("{}", str::from_utf8(&git_version)?.trim());
 65  
 66      let listener = DualListener::bind(&options.listen).await?;
 67      tracing::info!("listening on {:?}", &options.listen);
 68  
 69      let profile = Profile::load()?;
 70      let request_id = RequestId::new();
 71  
 72      tracing::info!("using radicle home at {}", profile.home().path().display());
 73  
 74      let web_config = api::WebConfig::from_profile(&profile);
 75      let profile = Arc::new(profile);
 76      let ctx = api::Context::new(profile.clone(), web_config.clone(), &options);
 77  
 78      #[cfg(unix)]
 79      tokio::spawn(async move {
 80          let mut sighup = signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler");
 81  
 82          loop {
 83              sighup.recv().await;
 84              tracing::info!("Received SIGHUP, reloading web configuration");
 85  
 86              match Profile::load() {
 87                  Ok(new_profile) => {
 88                      web_config
 89                          .update(|config| {
 90                              *config = new_profile.config.web.clone();
 91                          })
 92                          .await;
 93                      tracing::info!("Web configuration reloaded successfully");
 94                  }
 95                  Err(e) => {
 96                      tracing::error!("Failed to reload configuration: {:#}", e);
 97                      tracing::warn!("Continuing with previous configuration");
 98                  }
 99              }
100          }
101      });
102  
103      let app = router(options, profile, ctx)?
104          .layer(middleware::from_fn(tracing_middleware))
105          .layer(
106              TraceLayer::new_for_http()
107                  .make_span_with(move |request: &Request<Body>| {
108                      if let Some(forwarded) = request.headers().get("X-Forwarded-For").and_then(|s| s.to_str().ok()) {
109                          tracing::info_span!("request", id = %request_id.clone().next(), "X-Forwarded-For" = forwarded)
110                      } else {
111                          tracing::info_span!("request", id = %request_id.clone().next())
112                      }
113                  })
114                  .on_response(
115                      |response: &hyper::Response<Body>, latency: Duration, _span: &Span| {
116                          if let Some(info) = response.extensions().get::<TracingInfo>() {
117                              tracing::info!(
118                                  "{} \"{} {} {:?}\" {} {:?} {}",
119                                  match info.connect_info.0 {
120                                      DualAddr::Tcp(c) => c.to_string(),
121                                      #[cfg(unix)]
122                                      DualAddr::Uds(_) => "unix-socket".into()
123                                  },
124                                  info.method,
125                                  info.uri,
126                                  info.version,
127                                  ColoredStatus(response.status()),
128                                  latency,
129                                  Paint::dim(
130                                      response
131                                          .body()
132                                          .size_hint()
133                                          .exact()
134                                          .map(|n| n.to_string())
135                                          .unwrap_or("0".to_string())
136                                          .into()
137                                  ),
138                              );
139                          } else {
140                              tracing::info!("Processed");
141                          }
142                      },
143                  )
144          ).into_make_service_with_connect_info::<DualAddr>();
145  
146      axum::serve(listener, app)
147          .await
148          .map_err(anyhow::Error::from)
149  }
150  
151  /// Create a router consisting of other sub-routers.
152  fn router(options: Options, profile: Arc<Profile>, ctx: api::Context) -> anyhow::Result<Router> {
153      let api_router = api::router(ctx);
154      let git_router = git::router(profile.clone(), options.aliases);
155      let raw_router = raw::router(profile);
156  
157      let app = Router::new()
158          .route("/", get(root_index_handler))
159          .merge(git_router)
160          .nest("/api", api_router)
161          .nest("/raw", raw_router)
162          .layer(
163              CorsLayer::new()
164                  .max_age(Duration::from_secs(86400))
165                  .allow_origin(cors::Any)
166                  .allow_methods([Method::GET])
167                  .allow_headers([CONTENT_TYPE]),
168          );
169  
170      Ok(app)
171  }
172  
173  async fn root_index_handler() -> impl IntoResponse {
174      let response = serde_json::json!({
175          "welcome": "Welcome to the radicle-httpd JSON API, this service doesn't serve the Radicle Explorer web client.",
176          "version": format!("{}-{}", RADICLE_VERSION, env!("GIT_HEAD")),
177          "path": "/",
178          "links": [
179              {
180                  "href": "/api",
181                  "rel": "api",
182                  "type": "GET"
183              },
184              {
185                  "href": "/raw/:rid/:sha/*path",
186                  "rel": "file_by_commit",
187                  "type": "GET"
188              },
189              {
190                  "href": "/raw/:rid/head/*path",
191                  "rel": "file_by_canonical_head",
192                  "type": "GET"
193              },
194              {
195                  "href": "/raw/:rid/blobs/:oid",
196                  "rel": "file_by_oid",
197                  "type": "GET"
198              },
199              {
200                  "href": "/:rid/*request",
201                  "rel": "git",
202                  "type": "GET"
203              }
204          ]
205      });
206  
207      Json(response)
208  }
209  
210  pub mod logger {
211      use tracing::dispatcher::Dispatch;
212  
213      pub fn init() -> Result<(), tracing::subscriber::SetGlobalDefaultError> {
214          tracing::dispatcher::set_global_default(Dispatch::new(subscriber()))
215      }
216  
217      #[cfg(feature = "logfmt")]
218      pub fn subscriber() -> impl tracing::Subscriber {
219          use tracing_subscriber::layer::SubscriberExt as _;
220          use tracing_subscriber::EnvFilter;
221  
222          tracing_subscriber::Registry::default()
223              .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
224              .with(tracing_logfmt::layer())
225      }
226  
227      #[cfg(not(feature = "logfmt"))]
228      pub fn subscriber() -> impl tracing::Subscriber {
229          tracing_subscriber::FmtSubscriber::builder()
230              .with_target(false)
231              .with_max_level(tracing::Level::DEBUG)
232              .finish()
233      }
234  }
235  
236  #[cfg(test)]
237  mod routes {
238      use std::collections::HashMap;
239      use std::net::SocketAddr;
240  
241      use axum::extract::connect_info::MockConnectInfo;
242      use axum::http::StatusCode;
243      use axum_listener::DualAddr;
244  
245      use crate::test;
246  
247      #[tokio::test]
248      async fn test_invalid_route_returns_404() {
249          let tmp = tempfile::tempdir().unwrap();
250          let options = super::Options {
251              aliases: HashMap::new(),
252              listen: DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))),
253              cache: None,
254          };
255          let profile = test::profile(tmp.path(), [0xff; 32]);
256          let web_config = crate::api::WebConfig::from_profile(&profile);
257          let profile = std::sync::Arc::new(profile);
258          let ctx = crate::api::Context::new(profile.clone(), web_config, &options);
259          let app = super::router(options, profile, ctx)
260              .unwrap()
261              .layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from((
262                  [0, 0, 0, 0],
263                  8080,
264              )))));
265  
266          let response = test::get(&app, "/aa/a").await;
267  
268          assert_eq!(response.status(), StatusCode::NOT_FOUND);
269      }
270  }