//! The router: every route of SPEC §7 and the layers of SPEC §14.3.1 and §8.6. The handlers are in `api/*.rs`. use std::time::Instant; use axum::Router; use axum::extract::{DefaultBodyLimit, MatchedPath, Path, RawQuery, Request, State}; use axum::http::{HeaderMap, HeaderValue}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use crate::api::{ accounts, apk, categories, devices, health, invites, landing, me, pictures, proposal, rate_limit, recurring, setup, sync, }; use crate::error::{AppError, LoggedErrorCode}; use crate::{AppState, SERVER_VERSION, auth}; /// Request body limit (SPEC §8.6). pub const BODY_LIMIT_BYTES: usize = 1024 * 1024; /// Request body limit of `PUT /me/picture` (SPEC §8.6). pub const PICTURE_BODY_LIMIT_BYTES: usize = 5 * 1024 * 1024; /// Response header carrying the crate version (SPEC §14.3.1). pub const SERVER_VERSION_HEADER: &str = "x-kvitt-server-version"; /// Builds the application. pub fn router(state: AppState) -> Router { // E-1 to E-6: no authentication, no protocol check. let public = Router::new() .route("/health", get(health::get_health)) .route("/setup/status", get(setup::get_setup_status)) .route("/invites/accept", post(invites::accept_invite)) .route("/i/{code}", get(landing::get_invite_landing)) // `/i/` with an empty code: the landing page's HTML 404, not the JSON fallback. .route("/i/", get(empty_invite_code)) .route("/apk/latest", get(apk::get_apk_latest)) .route("/apk/files/{file_name}", get(apk::get_apk_file)); // E-7 to E-33: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`. let authenticated = Router::new() .route("/me", get(me::get_me).patch(me::patch_me)) .route( "/me/picture", put(pictures::put_me_picture) .layer(DefaultBodyLimit::max(PICTURE_BODY_LIMIT_BYTES)) .delete(pictures::delete_me_picture), ) .route("/users/{user_id}/picture", get(pictures::get_user_picture)) .route( "/invites", post(invites::create_invite).get(invites::list_invites), ) .route( "/invites/{invite_id}", axum::routing::delete(invites::cancel_invite), ) .route("/devices", get(devices::list_devices)) .route( "/devices/{device_id}", axum::routing::delete(devices::revoke_device), ) .route("/devices/current/push-token", put(devices::put_push_token)) .route("/accounts", post(accounts::create_account)) .route( "/accounts/{account_id}", axum::routing::patch(accounts::patch_account).delete(accounts::delete_account), ) .route( "/accounts/{account_id}/archive", post(accounts::archive_account), ) .route( "/accounts/{account_id}/unarchive", post(accounts::unarchive_account), ) .route("/categories", post(categories::create_category)) .route( "/categories/{category_id}", axum::routing::patch(categories::patch_category).delete(categories::delete_category), ) .route( "/categories/{category_id}/archive", post(categories::archive_category), ) .route( "/categories/{category_id}/unarchive", post(categories::unarchive_category), ) .route( "/recurring-templates", post(recurring::create_recurring_template), ) .route( "/recurring-templates/{template_id}", axum::routing::patch(recurring::patch_recurring_template) .delete(recurring::delete_recurring_template), ) .route("/sync", get(sync::sync_pull).post(sync::sync_push)) .route("/settlements/proposal", post(proposal::propose_settlement)) .route_layer(middleware::from_fn(auth::require_protocol)); Router::new() .merge(public) .merge(authenticated) .fallback(fallback) .layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES)) .layer(rate_limit::layer(&state)) .layer(middleware::from_fn(log_request)) .layer(middleware::map_response(add_server_version)) .with_state(state) } /// `GET /i/`: E-4 with an empty code, which the landing handler answers with its HTML 404 page. async fn empty_invite_code(state: State, headers: HeaderMap) -> Response { landing::get_invite_landing(state, headers, Path(String::new()), RawQuery(None)).await } /// Unknown paths: 404 `not_found` in the error shape. async fn fallback() -> AppError { AppError::not_found() } /// Every response carries `X-Kvitt-Server-Version` (SPEC §14.3.1). async fn add_server_version(mut response: Response) -> Response { response.headers_mut().insert( SERVER_VERSION_HEADER, HeaderValue::from_static(SERVER_VERSION), ); response } /// One log line per request with the fields SPEC §8.7 allows: method, route template (never the raw path, which can /// hold invite codes), status, latency, device id (recorded by the auth extractor) and error code. async fn log_request(request: Request, next: Next) -> Response { let method = request.method().to_string(); let route = request .extensions() .get::() .map_or_else(|| "".to_owned(), |p| p.as_str().to_owned()); let span = tracing::info_span!( "request", method = %method, route = %route, device_id = tracing::field::Empty ); let started = Instant::now(); let response = tracing::Instrument::instrument(next.run(request), span.clone()).await; let latency_ms = started.elapsed().as_secs_f64() * 1000.0; let status = response.status().as_u16(); let error_code = response .extensions() .get::() .map_or("", |c| c.0.as_str()); span.in_scope(|| { tracing::info!(status, latency_ms, error_code, "request"); }); response.into_response() }