use std::{any::Any, sync::Once, time::Duration}; use askama::Template; use axum::{ Router, extract::rejection::{FormRejection, PathRejection, QueryRejection}, http::StatusCode, middleware::from_fn_with_state, response::{Html, IntoResponse, Redirect, Response}, }; use conduwuit_service::{Services, state}; use tower_http::catch_panic::CatchPanicLayer; use tower_sec_fetch::SecFetchLayer; use tower_sessions::{ExpiredDeletion, SessionManagerLayer, cookie::SameSite}; use crate::{ pages::TemplateContext, session::{LoginQuery, store::RocksDbSessionStore}, }; mod extract; mod pages; mod session; type State = state::State; const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \ please contact the team @ https://continuwuity.org"; const ROUTE_PREFIX: &str = conduwuit_core::ROUTE_PREFIX; #[derive(Debug, thiserror::Error)] enum WebError { #[error("Failed to validate form body: {0}")] ValidationError(#[from] validator::ValidationErrors), #[error("{0}")] QueryRejection(#[from] QueryRejection), #[error("{0}")] FormRejection(#[from] FormRejection), #[error("{0}")] PathRejection(#[from] PathRejection), #[error("{0}")] BadRequest(String), #[error("This page does not exist.")] NotFound, #[error("You are not allowed to request this page: {0}")] Forbidden(String), #[error("You must log in to access this page")] LoginRequired(LoginQuery), #[error("Failed to render template: {0}")] Render(#[from] askama::Error), #[error("{0}")] InternalError(#[from] conduwuit_core::Error), #[error("Request handler panicked! {0}")] Panic(String), } impl IntoResponse for WebError { fn into_response(self) -> Response { #[derive(Debug, Template)] #[template(path = "error.html.j2")] struct Error { error: WebError, status: StatusCode, context: TemplateContext, } if let Self::LoginRequired(query) = self { return Redirect::to(&format!( "{}/account/login?{}", ROUTE_PREFIX, serde_urlencoded::to_string(query).unwrap() )) .into_response(); } let status = match &self { | Self::ValidationError(_) | Self::BadRequest(_) | Self::QueryRejection(_) | Self::FormRejection(_) | Self::InternalError(_) => StatusCode::BAD_REQUEST, | Self::NotFound => StatusCode::NOT_FOUND, | Self::Forbidden(_) => StatusCode::FORBIDDEN, | Self::LoginRequired(_) => { unreachable!("LoginRequired is handled earlier") }, | _ => StatusCode::INTERNAL_SERVER_ERROR, }; let template = Error { error: self, status, context: TemplateContext { // Statically set false to prevent error pages from being indexed. allow_indexing: false, csp_nonce: String::new(), }, }; if let Ok(body) = template.render() { (status, Html(body)).into_response() } else { (status, CATASTROPHIC_FAILURE).into_response() } } } static STORE_CLEANUP_TASK: Once = Once::new(); pub fn build(services: &Services) -> Router { #[allow(clippy::wildcard_imports)] use pages::*; let store = RocksDbSessionStore::new(&services.db); STORE_CLEANUP_TASK.call_once(|| { services.server.runtime().spawn( store .clone() .continuously_delete_expired(Duration::from_hours(1)), ); }); Router::new() .merge(index::build()) .nest( "/_continuwuity/", Router::new() .nest("/about", about::build()) .nest("/account/", account::build()) .merge(debug::build()) .nest("/oauth2/", oauth::build()) .merge(resources::build()) .merge(threepid::build()) .fallback(async || WebError::NotFound), ) .layer( SessionManagerLayer::new(store) .with_name("_c10y_session") .with_same_site(SameSite::Lax), ) .layer(CatchPanicLayer::custom(|panic: Box| { let details = if let Some(s) = panic.downcast_ref::() { s.clone() } else if let Some(s) = panic.downcast_ref::<&str>() { (*s).to_owned() } else { "(opaque panic payload)".to_owned() }; WebError::Panic(details).into_response() })) .layer(from_fn_with_state(services.config.clone(), template_context_middleware)) .layer(SecFetchLayer::new(|policy| { policy.allow_safe_methods().reject_missing_metadata(); })) }