#![forbid(unsafe_code)] #![warn(clippy::todo)] use std::sync::Arc; use axum::extract::{FromRef, FromRequestParts}; use axum_extra::extract::{cookie::Key, SignedCookieJar}; use database::{FileStorage, PostgresStorage, Storage}; use indieauth::backend::{AuthBackend, FileBackend as FileAuthBackend}; use kittybox_util::queue::JobQueue; use media::storage::{MediaStore, file::FileStore as FileMediaStore}; use tokio::{sync::{Mutex, RwLock}, task::JoinSet}; use webmentions::queue::PostgresJobQueue; /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. pub mod database; pub mod frontend; pub mod media; pub mod micropub; pub mod indieauth; pub mod webmentions; pub mod login; //pub mod admin; const OAUTH2_SOFTWARE_ID: &str = "6f2eee84-c22c-4c9e-b900-10d4e97273c8"; #[derive(Clone)] pub struct AppState where A: AuthBackend + Sized + 'static, S: Storage + Sized + 'static, M: MediaStore + Sized + 'static, Q: JobQueue + Sized { pub auth_backend: A, pub storage: S, pub media_store: M, pub job_queue: Q, pub http: reqwest::Client, pub background_jobs: Arc>>, pub cookie_key: Key, pub session_store: SessionStore } pub type SessionStore = Arc>>; #[derive(Debug, Clone)] #[repr(transparent)] pub struct Session(kittybox_indieauth::ProfileUrl); impl std::ops::Deref for Session { type Target = kittybox_indieauth::ProfileUrl; fn deref(&self) -> &Self::Target { &self.0 } } pub struct NoSessionError; impl axum::response::IntoResponse for NoSessionError { fn into_response(self) -> axum::response::Response { // TODO: prettier error message (axum::http::StatusCode::UNAUTHORIZED, "You are not logged in, but this page requires a session.").into_response() } } #[async_trait::async_trait] impl FromRequestParts for Session where SessionStore: FromRef, Key: FromRef, S: Send + Sync, { type Rejection = NoSessionError; async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result { let jar = SignedCookieJar::::from_request_parts(parts, state).await.unwrap(); let session_store = SessionStore::from_ref(state).read_owned().await; tracing::debug!("Cookie jar: {:#?}", jar); let cookie = match jar.get("session_id") { Some(cookie) => { tracing::debug!("Session ID cookie: {}", cookie); cookie }, None => { return Err(NoSessionError) } }; session_store.get( &dbg!(cookie.value_trimmed()) .parse() .map_err(|err| { tracing::error!("Error parsing cookie: {}", err); NoSessionError })? ).cloned().ok_or(NoSessionError) } } // This is really regrettable, but I can't write: // // ```compile-error // impl FromRef> for A // where A: AuthBackend, S: Storage, M: MediaStore { // fn from_ref(input: &AppState) -> A { // input.auth_backend.clone() // } // } // ``` // // ...because of the orphan rule. // // I wonder if this would stifle external implementations. I think it // shouldn't, because my AppState type is generic, and since the // target type is local, the orphan rule will not kick in. You just // have to repeat this magic invocation. impl FromRef> for FileAuthBackend where S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.auth_backend.clone() } } impl FromRef> for PostgresStorage where A: AuthBackend, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.storage.clone() } } impl FromRef> for FileStorage where A: AuthBackend, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.storage.clone() } } impl FromRef> for FileMediaStore // where A: AuthBackend, S: Storage where A: AuthBackend, S: Storage, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.media_store.clone() } } impl FromRef> for Key where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.cookie_key.clone() } } impl FromRef> for reqwest::Client where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.http.clone() } } impl FromRef> for Arc>> where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.background_jobs.clone() } } #[cfg(feature = "sqlx")] impl FromRef> for PostgresJobQueue where A: AuthBackend, S: Storage, M: MediaStore { fn from_ref(input: &AppState) -> Self { input.job_queue.clone() } } impl FromRef> for SessionStore where A: AuthBackend, S: Storage, M: MediaStore, Q: JobQueue { fn from_ref(input: &AppState) -> Self { input.session_store.clone() } } pub mod companion { use std::{collections::HashMap, sync::Arc}; use axum::{ extract::{Extension, Path}, response::{IntoResponse, Response} }; #[derive(Debug, Clone, Copy)] struct Resource { data: &'static [u8], mime: &'static str } impl IntoResponse for &Resource { fn into_response(self) -> Response { (axum::http::StatusCode::OK, [("Content-Type", self.mime)], self.data).into_response() } } // TODO replace with the "phf" crate someday type ResourceTable = Arc>; #[tracing::instrument] async fn map_to_static( Path(name): Path, Extension(resources): Extension ) -> Response { tracing::debug!("Searching for {} in the resource table...", name); match resources.get(name.as_str()) { Some(res) => res.into_response(), None => { #[cfg(debug_assertions)] tracing::error!("Not found"); (axum::http::StatusCode::NOT_FOUND, [("Content-Type", "text/plain")], "Not found. Sorry.".as_bytes()).into_response() } } } pub fn router() -> axum::Router { let resources: ResourceTable = { let mut map = HashMap::new(); macro_rules! register_resource { ($map:ident, $prefix:expr, ($filename:literal, $mime:literal)) => {{ $map.insert($filename, Resource { data: include_bytes!(concat!($prefix, $filename)), mime: $mime }) }}; ($map:ident, $prefix:expr, ($filename:literal, $mime:literal), $( ($f:literal, $m:literal) ),+) => {{ register_resource!($map, $prefix, ($filename, $mime)); register_resource!($map, $prefix, $(($f, $m)),+); }}; } register_resource! { map, concat!(env!("OUT_DIR"), "/", "companion", "/"), ("index.html", "text/html; charset=\"utf-8\""), ("main.js", "text/javascript"), ("micropub_api.js", "text/javascript"), ("indieauth.js", "text/javascript"), ("base64.js", "text/javascript"), ("style.css", "text/css") }; Arc::new(map) }; axum::Router::new() .route( "/:filename", axum::routing::get(map_to_static) .layer(Extension(resources)) ) } } async fn teapot_route() -> impl axum::response::IntoResponse { use axum::http::{header, StatusCode}; (StatusCode::IM_A_TEAPOT, [(header::CONTENT_TYPE, "text/plain")], "Sorry, can't brew coffee yet!") } async fn health_check( axum::extract::State(data): axum::extract::State, ) -> impl axum::response::IntoResponse where D: crate::database::Storage { (axum::http::StatusCode::OK, std::borrow::Cow::Borrowed("OK")) } pub async fn compose_kittybox() -> axum::Router where A: AuthBackend + 'static + FromRef, S: Storage + 'static + FromRef, M: MediaStore + 'static + FromRef, Q: kittybox_util::queue::JobQueue + FromRef, reqwest::Client: FromRef, Arc>>: FromRef, crate::SessionStore: FromRef, axum_extra::extract::cookie::Key: FromRef, St: Clone + Send + Sync + 'static { use axum::routing::get; axum::Router::new() .route("/", get(crate::frontend::homepage::)) .fallback(get(crate::frontend::catchall::)) .route("/.kittybox/micropub", crate::micropub::router::()) .route("/.kittybox/onboarding", crate::frontend::onboarding::router::()) .nest("/.kittybox/media", crate::media::router::()) .merge(crate::indieauth::router::()) .merge(crate::webmentions::router::()) .route("/.kittybox/health", get(health_check::)) .nest("/.kittybox/login", crate::login::router::()) .route( "/.kittybox/static/:path", axum::routing::get(crate::frontend::statics) ) .route("/.kittybox/coffee", get(teapot_route)) .nest("/.kittybox/micropub/client", crate::companion::router::()) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::catch_panic::CatchPanicLayer::new()) .layer(tower_http::sensitive_headers::SetSensitiveHeadersLayer::new([ axum::http::header::AUTHORIZATION, axum::http::header::COOKIE, axum::http::header::SET_COOKIE, ])) }