1use crate::auth;
2use crate::error::ApiError;
3use crate::routes;
4use crate::state::AppState;
5use axum::extract::{DefaultBodyLimit, Request, State};
6use axum::http::{header, HeaderValue, Method};
7use axum::middleware::{self, Next};
8use axum::response::Response;
9use axum::routing::{get, post, put};
10use axum::Router;
11use tower_http::cors::CorsLayer;
12use tower_http::trace::TraceLayer;
13
14pub fn router(state: AppState) -> Router {
15 router_with_cors(state, None)
16}
17
18pub async fn serve(
20 listener: tokio::net::TcpListener,
21 state: AppState,
22 allowed_origins: Option<Vec<String>>,
23 web_dir: Option<&std::path::Path>,
24) -> anyhow::Result<()> {
25 axum::serve(listener, router_with_web(state, allowed_origins, web_dir))
26 .with_graceful_shutdown(async {
27 let _ = tokio::signal::ctrl_c().await;
28 tracing::info!("shutting down");
29 })
30 .await?;
31 Ok(())
32}
33
34pub fn router_with_web(
37 state: AppState,
38 allowed_origins: Option<Vec<String>>,
39 web_dir: Option<&std::path::Path>,
40) -> Router {
41 let api = router_with_cors(state, allowed_origins);
42
43 match web_dir {
44 Some(dir) => api.merge(crate::web::router(dir)),
45 None => api.fallback(crate::web::missing),
46 }
47}
48
49pub fn router_with_cors(state: AppState, allowed_origins: Option<Vec<String>>) -> Router {
56 let public = Router::new().route("/api/v1/health", get(routes::health));
57
58 let protected = Router::new()
59 .route("/api/v1/revision", get(routes::revision))
60 .route("/api/v1/accounts", get(routes::accounts))
61 .route("/api/v1/addresses", get(routes::addresses))
62 .route("/api/v1/tags", get(routes::tags))
63 .route("/api/v1/counts", post(routes::counts))
64 .route("/api/v1/lists", get(routes::lists))
65 .route("/api/v1/threads", get(routes::threads))
66 .route("/api/v1/threads/{id}", get(routes::thread))
67 .route("/api/v1/messages/{id}", get(routes::message))
68 .route("/api/v1/messages/{id}/body", get(routes::body))
69 .route("/api/v1/messages/{id}/parts/{part}", get(routes::part))
70 .route("/api/v1/tags", post(routes::tag))
71 .route("/api/v1/sync", post(routes::sync))
72 .route(
73 "/api/v1/send",
74 post(routes::send).layer(DefaultBodyLimit::max(36 * 1024 * 1024)),
79 )
80 .route("/api/v1/events", get(routes::events))
81 .route("/api/v1/config", get(routes::config))
82 .route("/api/v1/config", put(routes::save_config))
83 .route("/api/v1/themes", get(routes::themes))
84 .route("/api/v1/theme", get(routes::theme))
85 .route("/api/v1/theme", put(routes::save_theme))
86 .layer(middleware::from_fn_with_state(state.clone(), require_token));
87
88 public
89 .merge(protected)
90 .layer(cors(allowed_origins))
91 .layer(TraceLayer::new_for_http())
92 .with_state(state)
93}
94
95fn cors(allowed_origins: Option<Vec<String>>) -> CorsLayer {
96 let layer = CorsLayer::new()
97 .allow_methods([Method::GET, Method::POST, Method::PUT, Method::OPTIONS])
98 .allow_headers([
99 header::AUTHORIZATION,
100 header::CONTENT_TYPE,
101 header::IF_NONE_MATCH,
102 ])
103 .expose_headers([header::ETAG]);
104
105 let parsed: Vec<HeaderValue> = allowed_origins
106 .unwrap_or_default()
107 .iter()
108 .filter_map(|o| o.parse().ok())
109 .collect();
110
111 if parsed.is_empty() {
112 layer.allow_origin(tower_http::cors::Any)
113 } else {
114 layer.allow_origin(parsed)
115 }
116}
117
118async fn require_token(
119 State(state): State<AppState>,
120 request: Request,
121 next: Next,
122) -> Result<Response, ApiError> {
123 if !state.requires_auth().await {
124 return Ok(next.run(request).await);
125 }
126
127 let presented = request
128 .headers()
129 .get(header::AUTHORIZATION)
130 .and_then(|v| v.to_str().ok());
131
132 let token = auth::bearer(presented)
133 .or_else(|| query_token(request.uri().query()))
134 .ok_or(ApiError::Unauthorized)?;
135
136 let name = {
137 let tokens = state.tokens.read().await;
138 tokens.verify(token).map(|t| t.name.clone())
139 };
140
141 match name {
142 Some(name) => {
143 tracing::debug!(device = %name, "authenticated");
144 Ok(next.run(request).await)
145 }
146 None => Err(ApiError::Unauthorized),
147 }
148}
149
150fn query_token(query: Option<&str>) -> Option<&str> {
151 query?
152 .split('&')
153 .find_map(|pair| pair.strip_prefix("access_token="))
154 .filter(|t| !t.is_empty())
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn finds_a_token_in_the_query_string() {
163 assert_eq!(query_token(Some("access_token=abc")), Some("abc"));
164 assert_eq!(query_token(Some("x=1&access_token=abc")), Some("abc"));
165 }
166
167 #[test]
168 fn ignores_a_query_string_without_a_token() {
169 assert_eq!(query_token(None), None);
170 assert_eq!(query_token(Some("q=tag:inbox")), None);
171 assert_eq!(query_token(Some("access_token=")), None);
172 }
173}