Skip to main content

ecr_server/
app.rs

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
18/// Serves until ctrl-c. Owning this here is what keeps axum out of the CLI.
19pub 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
34/// The API plus the built web client on the same origin, which is what lets a
35/// browser reach `http://host:8383` and just work.
36pub 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
49/// `allowed_origins` restricts the browser origins that may call the API.
50/// The default is deliberately permissive: this API authenticates with a
51/// bearer token and never uses cookies, so the Origin header is not a
52/// security boundary — a hardcoded list would only break real deployments
53/// (a tailnet hostname, a phone, a different port) while stopping nothing,
54/// since a non-browser client ignores CORS entirely.
55pub 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            // A draft carries its attachments base64 in the same request, so
75            // this route alone needs room for the 25MB cap plus the ~4/3
76            // encoding overhead. Axum's 2MB default truncated the body, which
77            // surfaced as an unintelligible parse error rather than a refusal.
78            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}