1use axum::body::Body;
2use axum::extract::{MatchedPath, Query, State};
3use axum::http::{HeaderMap, Request, StatusCode};
4use axum::middleware::{from_fn, Next};
5use axum::response::{Html, IntoResponse, Response};
6use axum::routing::get;
7use axum::{Json, Router};
8use freighter_api_types::index::request::ListQuery;
9use freighter_api_types::index::response::ListAll;
10use freighter_api_types::index::IndexProvider;
11use freighter_api_types::storage::StorageProvider;
12use freighter_auth::AuthProvider;
13use metrics::{histogram, counter};
14use serde::Deserialize;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18use tokio::time::timeout;
19use tokio::try_join;
20use tower_http::catch_panic::CatchPanicLayer;
21use tower_http::classify::StatusInRangeAsFailures;
22use tower_http::trace::{DefaultOnFailure, TraceLayer};
23
24pub mod index;
25
26pub mod api;
27
28pub mod downloads;
29
30#[derive(Clone, Deserialize)]
31pub struct ServiceConfig {
32 pub address: SocketAddr,
33 pub download_endpoint: String,
34 pub api_endpoint: String,
35 pub metrics_address: SocketAddr,
36 #[serde(default = "default_true")]
37 pub allow_registration: bool,
38
39 #[serde(default = "default_true")]
42 pub auth_required: bool,
43}
44
45pub struct ServiceState<I, S, A> {
46 pub config: ServiceConfig,
47 pub index: I,
48 pub storage: S,
49 pub auth: A,
50}
51
52impl<I, S, A> ServiceState<I, S, A> {
53 pub fn new(config: ServiceConfig, index: I, storage: S, auth: A) -> Self {
54 Self {
55 config,
56 index,
57 storage,
58 auth,
59 }
60 }
61}
62
63pub fn router<I, S, A>(
64 config: ServiceConfig,
65 index_client: I,
66 storage_client: S,
67 auth_client: A,
68) -> Router
69where
70 I: IndexProvider + Send + Sync + 'static,
71 S: StorageProvider + Clone + Send + Sync + 'static,
72 A: AuthProvider + Send + Sync + 'static,
73{
74 let state = Arc::new(ServiceState::new(
75 config,
76 index_client,
77 storage_client,
78 auth_client,
79 ));
80
81 Router::new()
82 .nest("/downloads", downloads::downloads_router())
83 .nest("/index", index::index_router())
84 .nest("/api/v1/crates", api::api_router())
85 .route("/me", get(register))
86 .route("/all", get(list))
87 .route("/healthcheck", get(healthcheck))
88 .route("/", get(root_page))
89 .with_state(state)
90 .fallback(handle_global_fallback)
91 .layer(CatchPanicLayer::custom(|_| {
92 counter!("freighter_panics_total").increment(1);
93
94 StatusCode::INTERNAL_SERVER_ERROR.into_response()
95 }))
96 .layer(
97 TraceLayer::new(StatusInRangeAsFailures::new(400..=599).into_make_classifier())
98 .make_span_with(|request: &Request<Body>| {
99 let method = request.method();
100 let uri = request.uri();
101
102 tracing::info_span!("http-request", ?method, ?uri)
103 })
104 .on_failure(DefaultOnFailure::new()),
105 )
106 .layer(from_fn(metrics_layer))
107}
108
109async fn metrics_layer<B>(request: Request<B>, next: Next<B>) -> Response {
110 let timer = Instant::now();
111
112 let path = if let Some(path) = request.extensions().get::<MatchedPath>() {
113 path.as_str().to_string()
114 } else {
115 request.uri().path().to_string()
116 };
117
118 let response = next.run(request).await;
119
120 let elapsed = timer.elapsed();
121
122 let code = response.status().as_u16().to_string();
123
124 histogram!("freighter_request_duration_seconds", "code" => code, "endpoint" => path)
125 .record(elapsed);
126
127 response
128}
129
130pub async fn root_page<I, S, A>(
131 State(state): State<Arc<ServiceState<I, S, A>>>,
132) -> String {
133 format!(
134 "This is root of the Freighter server. There's nothing here.
135The API endpoint is at {}.
136The download endpoint is at {}.
137Auth is always required: {}",
138 state.config.api_endpoint,
139 state.config.download_endpoint,
140 state.config.auth_required,
141 )
142}
143
144pub async fn register() -> Html<&'static str> {
145 Html(include_str!("../static/register.html"))
146}
147
148async fn list<I, S, A>(
149 headers: HeaderMap,
150 State(state): State<Arc<ServiceState<I, S, A>>>,
151 Query(query): Query<ListQuery>,
152) -> axum::response::Result<Json<ListAll>>
153where
154 I: IndexProvider,
155 A: AuthProvider + Sync,
156{
157 if state.config.auth_required {
158 let token = state.auth.token_from_headers(&headers)?.ok_or(StatusCode::UNAUTHORIZED)?;
159 state.auth.auth_view_full_index(token).await?;
160 }
161
162 let search_results = state.index.list(&query).await?;
163
164 Ok(Json(search_results))
165}
166
167async fn healthcheck<I, S, A>(State(state): State<Arc<ServiceState<I, S, A>>>) -> axum::response::Result<String>
168where
169 I: IndexProvider,
170 S: StorageProvider,
171 A: AuthProvider + Sync,
172{
173 let check_time = Duration::from_secs(4);
174 let label = |label, res: Result<Result<(), anyhow::Error>, _>| match res {
175 Ok(Ok(())) => Ok(()),
177 Ok(Err(e)) => {
178 for e in e.chain() {
179 tracing::error!("{label} healthcheck: {e}");
180 }
181 Err(format!("{label} failed"))
182 },
183 Err(_) => Err(format!("{label} timed out")),
184 };
185
186 try_join! {
187 async { label("auth", timeout(check_time, state.auth.healthcheck()).await) },
188 async { label("index", timeout(check_time, state.index.healthcheck()).await) },
189 async { label("storage", timeout(check_time, state.storage.healthcheck()).await) },
190 }
191 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
192
193 Ok("OK".into())
194}
195
196pub async fn handle_global_fallback() -> (StatusCode, &'static str) {
197 (
198 StatusCode::NOT_FOUND,
199 "Freighter: There is no such URL at the root of the server",
200 )
201}
202
203#[inline(always)]
204fn default_true() -> bool {
205 true
206}
207