Files
continuwuity/src/web/mod.rs
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

157 lines
4.1 KiB
Rust
Raw Normal View History

use std::{any::Any, sync::Once, time::Duration};
2025-04-25 02:47:48 +01:00
use askama::Template;
use axum::{
Router,
extract::rejection::{FormRejection, PathRejection, QueryRejection},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
2025-04-25 02:47:48 +01:00
};
use conduwuit_service::{Services, state};
use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer};
2026-03-03 14:30:16 -05:00
use tower_sec_fetch::SecFetchLayer;
use tower_sessions::{ExpiredDeletion, SessionManagerLayer};
2025-04-25 02:47:48 +01:00
use crate::{
pages::TemplateContext,
session::{LoginQuery, store::RocksDbSessionStore},
};
mod extract;
mod pages;
mod session;
2025-04-25 02:47:48 +01:00
type State = state::State;
2026-02-13 09:58:35 -05:00
2026-03-18 13:18:53 -04:00
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;
2025-04-25 02:47:48 +01:00
#[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),
2026-03-18 13:18:53 -04:00
#[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),
2026-03-18 13:18:53 -04:00
#[error("Failed to render template: {0}")]
Render(#[from] askama::Error),
#[error("{0}")]
InternalError(#[from] conduwuit_core::Error),
#[error("Request handler panicked! {0}")]
Panic(String),
2025-04-25 02:47:48 +01:00
}
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,
2025-04-25 02:47:48 +01:00
}
if let Self::LoginRequired(query) = self {
return Redirect::to(&format!(
"{}/account/login?{}",
ROUTE_PREFIX,
serde_urlencoded::to_string(query).unwrap()
))
.into_response();
}
2025-04-25 02:47:48 +01:00
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,
2025-04-25 02:47:48 +01:00
};
let template = Error {
error: self,
status,
context: TemplateContext {
2026-03-18 13:18:53 -04:00
// Statically set false to prevent error pages from being indexed.
allow_indexing: false,
},
};
if let Ok(body) = template.render() {
(status, Html(body)).into_response()
2025-04-25 02:47:48 +01:00
} else {
2026-03-18 13:18:53 -04:00
(status, CATASTROPHIC_FAILURE).into_response()
2025-04-25 02:47:48 +01:00
}
}
}
static STORE_CLEANUP_TASK: Once = Once::new();
pub fn build(services: &Services) -> Router<state::State> {
#[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)),
);
});
2026-03-18 12:31:27 -04:00
Router::new()
.merge(index::build())
2026-03-18 13:18:53 -04:00
.nest(
"/_continuwuity/",
Router::new()
.nest("/account/", account::build())
.merge(debug::build())
.nest("/oauth2/", oauth::build())
.merge(resources::build())
.merge(threepid::build())
2026-03-18 13:18:53 -04:00
.fallback(async || WebError::NotFound),
)
.layer(SessionManagerLayer::new(store).with_name("_c10y_session"))
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
let details = if let Some(s) = panic.downcast_ref::<String>() {
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(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; img-src 'self' https: data:;"),
))
2026-03-03 14:30:16 -05:00
.layer(SecFetchLayer::new(|policy| {
policy.allow_safe_methods().reject_missing_metadata();
}))
}