use crate::config::NextcloudLoginConfig; use axum::Router; use axum::body::{Body, HttpBody}; use axum::extract::{DefaultBodyLimit, Request}; use axum::middleware::Next; use axum::response::{Redirect, Response}; use axum::routing::{any, options}; use axum_extra::TypedHeader; use headers::{HeaderMapExt, UserAgent}; use http::header::CONNECTION; use http::{HeaderValue, StatusCode}; use rustical_caldav::caldav_router; use rustical_carddav::carddav_router; use rustical_frontend::nextcloud_login::nextcloud_login_router; use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_oidc::OidcConfig; use rustical_store::auth::AuthenticationProvider; use rustical_store::{ AddressbookStore, CalendarStore, CombinedCalendarStore, PrefixedCalendarStore, SubscriptionStore, }; use std::sync::Arc; use std::time::Duration; use tower_http::catch_panic::CatchPanicLayer; use tower_http::classify::ServerErrorsFailureClass; use tower_http::trace::TraceLayer; use tower_sessions::cookie::SameSite; use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer}; use tracing::Span; use tracing::field::display; #[allow( clippy::too_many_arguments, clippy::too_many_lines, clippy::cognitive_complexity )] pub fn make_app< AS: AddressbookStore + PrefixedCalendarStore, CS: CalendarStore, S: SubscriptionStore, >( addr_store: Arc, cal_store: Arc, subscription_store: Arc, auth_provider: Arc, frontend_config: FrontendConfig, oidc_config: Option, nextcloud_login_config: &NextcloudLoginConfig, dav_push_enabled: bool, session_cookie_samesite_strict: bool, payload_limit_mb: usize, ) -> Router<()> { let birthday_store = addr_store.clone(); let combined_cal_store = Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store)); let mut router = Router::new() // endpoint to be used by healthcheck to see if rustical is online .route("/ping", axum::routing::get(async || "Pong!")) .merge(caldav_router( "/caldav", auth_provider.clone(), combined_cal_store.clone(), subscription_store.clone(), false, )) .merge(caldav_router( "/caldav-compat", auth_provider.clone(), combined_cal_store.clone(), subscription_store.clone(), true, )) .route( "/.well-known/caldav", any(async |TypedHeader(ua): TypedHeader| { if ua.as_str().contains("remindd") || ua.as_str().contains("dataaccessd") { // remindd is an Apple Calendar User Agent // Even when explicitly configuring a principal URL in Apple Calendar Apple // will not respect that configuration but call /.well-known/caldav, // so sadly we have to do this user-agent filtering. :( // (I should have never gotten an Apple device) return Redirect::permanent("/caldav-compat"); } Redirect::permanent("/caldav") }), ) .merge(carddav_router( "/carddav", auth_provider.clone(), addr_store.clone(), subscription_store.clone(), )); // GNOME Accounts needs to discover a WebDAV Files endpoint to complete the setup // It looks at / as well as /remote.php/dav (Nextcloud) // This is not nice but we offer this as a sacrificial route to ensure the CalDAV/CardDAV setup // works. // See: // https://github.com/GNOME/gnome-online-accounts/blob/master/src/goabackend/goadavclient.c // https://github.com/GNOME/gnome-online-accounts/blob/master/src/goabackend/goawebdavprovider.c router = router.route( "/remote.php/dav", options(async || { let mut resp = Response::builder().status(StatusCode::OK); resp.headers_mut() .unwrap() .insert("DAV", HeaderValue::from_static("1")); resp.body(Body::empty()).unwrap() }), ); let session_store = MemoryStore::default(); if frontend_config.enabled { router = router.merge(frontend_router( "/frontend", auth_provider.clone(), combined_cal_store, addr_store, frontend_config, oidc_config, )); } if nextcloud_login_config.enabled { router = router.nest("/index.php/login/v2", nextcloud_login_router(auth_provider)); } if dav_push_enabled { router = router.merge(rustical_dav_push::subscription_service(subscription_store)); } router .layer( SessionManagerLayer::new(session_store) .with_name("rustical_session") .with_secure(true) .with_same_site(if session_cookie_samesite_strict { SameSite::Strict } else { SameSite::Lax }) .with_expiry(Expiry::OnInactivity( tower_sessions::cookie::time::Duration::hours(2), )), ) .layer(CatchPanicLayer::new()) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { tracing::info_span!( "http-request", status = tracing::field::Empty, otel.name = tracing::field::display(format!( "{} {}", request.method(), request.uri() )), ua = tracing::field::Empty, ) }) .on_request(|req: &Request, span: &Span| { span.record("method", display(req.method())); span.record("path", display(req.uri())); if let Some(ua) = req.headers().typed_get::() { span.record("ua", display(ua)); } }) .on_response(|response: &Response, _latency: Duration, span: &Span| { span.record("status", display(response.status())); if response.status().is_server_error() { tracing::error!("server error"); } else if response.status().is_client_error() { match response.status() { StatusCode::UNAUTHORIZED => { // The iOS client always tries an unauthenticated request first so // logging 401's as errors would clog up our logs tracing::debug!("unauthorized"); } StatusCode::NOT_FOUND => { // Clients like GNOME Calendar will try to reach /remote.php/webdav // quite often clogging up the logs tracing::info!("client error"); } _ => { tracing::error!("client error"); } } } }) .on_failure( |_error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| { tracing::error!("something went wrong"); }, ), ) .layer(axum::middleware::from_fn( async |req: Request, next: Next| { // Closes the connection if the request body might've not been fully consumed // Otherwise subsequent requests reusing the connection might fail. // See https://github.com/lennart-k/rustical/issues/77 let body_empty = req.body().is_end_stream(); let mut response = next.run(req).await; if !body_empty && (response.status().is_server_error() || response.status().is_client_error()) { response .headers_mut() .insert(CONNECTION, HeaderValue::from_static("close")); } response }, )) .layer(DefaultBodyLimit::max(payload_limit_mb * 1000 * 1000)) }