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    // Ahead of the question, not after it. A server that started with no tokens
124    // serves everyone, and the first `ecr token new` is what ends that — read
125    // late, the server would go on serving everyone until it was restarted.
126    state.refresh_tokens().await;
127
128    if !state.requires_auth().await {
129        return Ok(next.run(request).await);
130    }
131
132    let presented = request
133        .headers()
134        .get(header::AUTHORIZATION)
135        .and_then(|v| v.to_str().ok());
136
137    let token = auth::bearer(presented)
138        .or_else(|| query_token(request.uri().query()))
139        .ok_or(ApiError::Unauthorized)?;
140
141    let name = {
142        let tokens = state.tokens.read().await;
143        tokens.verify(token).map(|t| t.name.clone())
144    };
145
146    match name {
147        Some(name) => {
148            tracing::debug!(device = %name, "authenticated");
149            Ok(next.run(request).await)
150        }
151        None => Err(ApiError::Unauthorized),
152    }
153}
154
155fn query_token(query: Option<&str>) -> Option<&str> {
156    query?
157        .split('&')
158        .find_map(|pair| pair.strip_prefix("access_token="))
159        .filter(|t| !t.is_empty())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn finds_a_token_in_the_query_string() {
168        assert_eq!(query_token(Some("access_token=abc")), Some("abc"));
169        assert_eq!(query_token(Some("x=1&access_token=abc")), Some("abc"));
170    }
171
172    #[test]
173    fn ignores_a_query_string_without_a_token() {
174        assert_eq!(query_token(None), None);
175        assert_eq!(query_token(Some("q=tag:inbox")), None);
176        assert_eq!(query_token(Some("access_token=")), None);
177    }
178}